Compare commits
6 Commits
84494827ad
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f4ca52a10 | |||
| 62c33c47c6 | |||
| 692c0794c5 | |||
| 848c4b8fd8 | |||
| 1a160c9415 | |||
| 85d8124a0c |
@@ -1,22 +1,25 @@
|
|||||||
import base64
|
|
||||||
import os
|
|
||||||
import pkgutil
|
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
from datetime import datetime, UTC
|
||||||
from cryptography import x509
|
from cryptography import x509
|
||||||
from cryptography.hazmat.primitives import serialization
|
from cryptography.hazmat.primitives import serialization
|
||||||
from flask import jsonify, Blueprint, request, make_response
|
from flask import jsonify, Blueprint, request, make_response
|
||||||
from flask_limiter import Limiter
|
from flask_limiter import Limiter
|
||||||
from flask_limiter.util import get_remote_address
|
from flask_limiter.util import get_remote_address
|
||||||
from jwt import decode, ExpiredSignatureError, InvalidTokenError, get_unverified_header
|
from jwt import decode, ExpiredSignatureError, InvalidTokenError, get_unverified_header
|
||||||
import importlib
|
|
||||||
import requests
|
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
|
from db.models.APIKey import APIKey
|
||||||
|
from db import get_db
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import pkgutil
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
import env_provider
|
import env_provider
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import importlib
|
||||||
|
import requests
|
||||||
|
|
||||||
_public_key_cache = {}
|
_public_key_cache = {}
|
||||||
_lock = Lock()
|
_lock = Lock()
|
||||||
@@ -96,18 +99,42 @@ def require_auth(roles=[]):
|
|||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
if request.method == "OPTIONS":
|
if request.method == "OPTIONS":
|
||||||
return '', 200
|
return '', 200
|
||||||
|
|
||||||
auth_header = request.headers.get('Authorization')
|
auth_header = request.headers.get('Authorization')
|
||||||
|
api_key_header = request.headers.get('X-API-Key')
|
||||||
|
|
||||||
|
if auth_header and api_key_header:
|
||||||
|
return jsonify({"error": "Cannot use both Bearer token and API Key authentication"}), 403
|
||||||
|
|
||||||
|
if api_key_header:
|
||||||
|
api_key = get_api_key(api_key_header)
|
||||||
|
if not api_key:
|
||||||
|
return jsonify({"error": "Invalid API key"}), 401
|
||||||
|
|
||||||
|
expire_time = api_key.expire.replace(tzinfo=UTC) if api_key.expire.tzinfo is None else api_key.expire
|
||||||
|
if datetime.now(UTC) > expire_time:
|
||||||
|
return jsonify({"error": "API key has expired"}), 401
|
||||||
|
|
||||||
|
if roles and not (set(roles) & set(api_key.roles)):
|
||||||
|
return jsonify({"error": "Forbidden, permission denied"}), 403
|
||||||
|
|
||||||
|
update_last_used(api_key)
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
if not auth_header or not auth_header.startswith('Bearer'):
|
if not auth_header or not auth_header.startswith('Bearer'):
|
||||||
return jsonify({"error": "Unauthorized"}), 401
|
return jsonify({"error": "Unauthorized"}), 401
|
||||||
|
|
||||||
token = auth_header.split(" ")[1]
|
token = auth_header.split(" ")[1]
|
||||||
|
|
||||||
decoded = verify_token(token)
|
decoded = verify_token(token)
|
||||||
if not decoded:
|
if not decoded:
|
||||||
return jsonify({"error": "Invalid or expired token"}), 401
|
return jsonify({"error": "Invalid or expired token"}), 401
|
||||||
|
|
||||||
user_roles = decoded.get("resource_access", {}).get(env_provider.KC_CLIENT_ID, {}).get("roles", [])
|
user_roles = decoded.get("resource_access", {}).get(env_provider.KC_CLIENT_ID, {}).get("roles", [])
|
||||||
if roles and not (set(roles) & set(user_roles)):
|
if roles and not (set(roles) & set(user_roles)):
|
||||||
print("auth failed")
|
print("auth failed")
|
||||||
return jsonify({"error": "Forbidden, permission denied"}), 403
|
return jsonify({"error": "Forbidden, permission denied"}), 403
|
||||||
|
|
||||||
print("auth success")
|
print("auth success")
|
||||||
return func(*args, **kwargs)
|
return func(*args, **kwargs)
|
||||||
return wrapper
|
return wrapper
|
||||||
@@ -169,3 +196,17 @@ def etag_response(f):
|
|||||||
return resp
|
return resp
|
||||||
return response
|
return response
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def generate_api_key(length=32):
|
||||||
|
alphabet = string.ascii_letters + string.digits
|
||||||
|
return ''.join(secrets.choice(alphabet) for _ in range(length))
|
||||||
|
|
||||||
|
def get_api_key(key):
|
||||||
|
with get_db() as session:
|
||||||
|
return session.query(APIKey).filter_by(key=key, is_active=True).first()
|
||||||
|
|
||||||
|
def update_last_used(api_key):
|
||||||
|
with get_db() as session:
|
||||||
|
api_key.last_used_at = datetime.now(UTC)
|
||||||
|
session.commit()
|
||||||
36
api/apikey/__init__.py
Normal file
36
api/apikey/__init__.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from api import get_api_key, generate_api_key
|
||||||
|
from db import get_db
|
||||||
|
from api import require_auth
|
||||||
|
from db.models.APIKey import APIKey
|
||||||
|
|
||||||
|
api_key_bp = Blueprint('apikey', __name__, url_prefix='/api/apikey')
|
||||||
|
|
||||||
|
@api_key_bp.route('/', methods=['POST'])
|
||||||
|
@require_auth(roles=['admin'])
|
||||||
|
def create_key():
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if not data or 'name' not in data:
|
||||||
|
return jsonify({"error": "Name is required"}), 400
|
||||||
|
roles = data.get('roles', [])
|
||||||
|
try:
|
||||||
|
with get_db() as session:
|
||||||
|
apikey = APIKey(key=generate_api_key(),name=data['name'], roles=roles)
|
||||||
|
session.add(apikey);
|
||||||
|
session.commit
|
||||||
|
return jsonify(apikey.to_dict()), 201
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@api_key_bp.route('/<key>', methods=['DELETE'])
|
||||||
|
@require_auth(roles=['admin'])
|
||||||
|
def revoke_key(key):
|
||||||
|
|
||||||
|
api_key = get_api_key(key)
|
||||||
|
with get_db() as session:
|
||||||
|
if not api_key:
|
||||||
|
return jsonify({"error": "API key not found"}), 404
|
||||||
|
api_key.is_active = False
|
||||||
|
session.commit()
|
||||||
|
return jsonify({"message": "API key revoked successfully"}), 200
|
||||||
@@ -268,7 +268,8 @@ def delete_markdown(markdown_id):
|
|||||||
"""
|
"""
|
||||||
Delete a markdown document.
|
Delete a markdown document.
|
||||||
|
|
||||||
This endpoint deletes an existing markdown document.
|
This endpoint deletes an existing markdown document and cascades the deletion
|
||||||
|
to related settings to avoid foreign key conflicts.
|
||||||
It requires authentication with the 'admin' role.
|
It requires authentication with the 'admin' role.
|
||||||
|
|
||||||
Request:
|
Request:
|
||||||
@@ -280,19 +281,61 @@ def delete_markdown(markdown_id):
|
|||||||
Response Codes:
|
Response Codes:
|
||||||
- 200: Deleted successfully
|
- 200: Deleted successfully
|
||||||
- 404: Markdown not found
|
- 404: Markdown not found
|
||||||
|
- 500: Server error during cascade deletion
|
||||||
"""
|
"""
|
||||||
with get_db() as session:
|
with get_db() as session:
|
||||||
|
try:
|
||||||
markdown = session.get(Markdown, markdown_id)
|
markdown = session.get(Markdown, markdown_id)
|
||||||
if markdown is None:
|
if markdown is None:
|
||||||
logger.error(f"failed to delete markdown: {markdown_id}")
|
logger.error(f"failed to delete markdown: {markdown_id}")
|
||||||
errno = RequestContext.get_error_id()
|
errno = RequestContext.get_error_id()
|
||||||
return jsonify({"error": f"file not found - {errno}"}), 404
|
return jsonify({"error": f"file not found - {errno}"}), 404
|
||||||
|
|
||||||
md = markdown.to_dict()
|
md = markdown.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
if markdown.setting_id:
|
||||||
|
markdown_setting = session.query(MarkdownSetting).get(markdown.setting_id)
|
||||||
|
if markdown_setting:
|
||||||
|
template_setting_id = markdown_setting.template_setting_id
|
||||||
|
permission_setting_id = markdown_setting.permission_setting_id
|
||||||
|
|
||||||
|
markdown_setting.template_setting_id = None
|
||||||
|
markdown_setting.permission_setting_id = None
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
if template_setting_id:
|
||||||
|
from db.models.MarkdownTemplateSetting import MarkdownTemplateSetting
|
||||||
|
template_setting = session.query(MarkdownTemplateSetting).get(template_setting_id)
|
||||||
|
if template_setting:
|
||||||
|
session.delete(template_setting)
|
||||||
|
|
||||||
|
if permission_setting_id:
|
||||||
|
permission_setting = session.query(MarkdownPermissionSetting).get(permission_setting_id)
|
||||||
|
if permission_setting:
|
||||||
|
session.delete(permission_setting)
|
||||||
|
|
||||||
|
session.delete(markdown_setting)
|
||||||
|
|
||||||
|
# Send webhook event before committing the transaction
|
||||||
|
# This ensures webhook handlers can still access related data
|
||||||
|
markdown_deleted.send(None, payload=md)
|
||||||
|
|
||||||
session.delete(markdown)
|
session.delete(markdown)
|
||||||
session.commit()
|
session.commit()
|
||||||
markdown_deleted.send(None, payload=md)
|
|
||||||
|
logger.info(f"Successfully deleted markdown {markdown_id} with cascade deletion")
|
||||||
return jsonify(md), 200
|
return jsonify(md), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
logger.error(f"Failed to delete markdown {markdown_id}: {e}")
|
||||||
|
logger.error(f"Exception type: {type(e).__name__}")
|
||||||
|
logger.error(f"Full traceback:\n{traceback.format_exc()}")
|
||||||
|
errno = RequestContext.get_error_id()
|
||||||
|
session.rollback()
|
||||||
|
return jsonify({"error": f"delete failed - {errno}"}), 500
|
||||||
|
|
||||||
|
|
||||||
@markdown_bp.route('/move_forward/<int:markdown_id>', methods=['PATCH'])
|
@markdown_bp.route('/move_forward/<int:markdown_id>', methods=['PATCH'])
|
||||||
@require_auth(roles=['admin'])
|
@require_auth(roles=['admin'])
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
from flask import jsonify, request
|
from flask import jsonify, request, Blueprint
|
||||||
|
|
||||||
import api
|
import api
|
||||||
from api import limiter, etag_response, require_auth
|
from api import limiter, etag_response, require_auth
|
||||||
from api.setting import setting_bp
|
|
||||||
from db import get_db
|
from db import get_db
|
||||||
from db.models.MarkdownSetting import MarkdownSetting
|
from db.models.MarkdownSetting import MarkdownSetting
|
||||||
|
|
||||||
|
markdown_setting_bp = Blueprint('markdown_setting', __name__, url_prefix='/api/setting/markdown')
|
||||||
@setting_bp.route('/markdown/<int:setting_id>', methods=['GET'])
|
@markdown_setting_bp.route('/<int:setting_id>', methods=['GET'])
|
||||||
@limiter.limit(api.get_rate_limit)
|
@limiter.limit(api.get_rate_limit)
|
||||||
@etag_response
|
@etag_response
|
||||||
def get_markdown_path(setting_id):
|
def get_markdown_path(setting_id):
|
||||||
@@ -33,7 +32,7 @@ def get_markdown_path(setting_id):
|
|||||||
return jsonify(setting.to_dict()), 200
|
return jsonify(setting.to_dict()), 200
|
||||||
|
|
||||||
|
|
||||||
@setting_bp.route('/markdown/', methods=['POST'])
|
@markdown_setting_bp.route('/', methods=['POST'])
|
||||||
@require_auth(roles=['admin'])
|
@require_auth(roles=['admin'])
|
||||||
def create_markdown_setting():
|
def create_markdown_setting():
|
||||||
"""
|
"""
|
||||||
@@ -68,7 +67,7 @@ def create_markdown_setting():
|
|||||||
except Exception:
|
except Exception:
|
||||||
return jsonify({"error": "failed to create setting"}), 500
|
return jsonify({"error": "failed to create setting"}), 500
|
||||||
|
|
||||||
@setting_bp.route('/markdown/<int:setting_id>', methods=['PUT', 'PATCH'])
|
@markdown_setting_bp.route('/<int:setting_id>', methods=['PUT', 'PATCH'])
|
||||||
@require_auth(roles=['admin'])
|
@require_auth(roles=['admin'])
|
||||||
def update_markdown_setting(setting_id):
|
def update_markdown_setting(setting_id):
|
||||||
"""
|
"""
|
||||||
@@ -106,7 +105,7 @@ def update_markdown_setting(setting_id):
|
|||||||
return jsonify({"error": "failed to update setting"}), 500
|
return jsonify({"error": "failed to update setting"}), 500
|
||||||
|
|
||||||
|
|
||||||
@setting_bp.route('/markdown/<int:setting_id>', methods=['DELETE'])
|
@markdown_setting_bp.route('/<int:setting_id>', methods=['DELETE'])
|
||||||
@require_auth(roles=['admin'])
|
@require_auth(roles=['admin'])
|
||||||
def delete_markdown_setting(setting_id):
|
def delete_markdown_setting(setting_id):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
from flask import jsonify, request
|
from flask import jsonify, request, Blueprint
|
||||||
import api
|
import api
|
||||||
from api import etag_response, limiter, require_auth
|
from api import etag_response, limiter, require_auth
|
||||||
from api.setting import setting_bp
|
|
||||||
from db import get_db
|
from db import get_db
|
||||||
from db.models.MarkdownPermissionSetting import MarkdownPermissionSetting
|
from db.models.MarkdownPermissionSetting import MarkdownPermissionSetting
|
||||||
|
|
||||||
|
markdown_permission_setting_bp = Blueprint('markdown_permission_setting', __name__, url_prefix='/api/setting/markdown/permission')
|
||||||
@setting_bp.route('/markdown/permission/<int:setting_id>/', methods=['GET'])
|
@markdown_permission_setting_bp.route('/<int:setting_id>', methods=['GET'])
|
||||||
@etag_response
|
@etag_response
|
||||||
@limiter.limit(api.get_rate_limit)
|
@limiter.limit(api.get_rate_limit)
|
||||||
def get_permission_setting(setting_id):
|
def get_permission_setting(setting_id):
|
||||||
@@ -32,7 +31,7 @@ def get_permission_setting(setting_id):
|
|||||||
return jsonify(setting.to_dict()), 200
|
return jsonify(setting.to_dict()), 200
|
||||||
|
|
||||||
|
|
||||||
@setting_bp.route('/markdown/permission/', methods=['POST'])
|
@markdown_permission_setting_bp.route('/', methods=['POST'])
|
||||||
@require_auth(roles=['admin'])
|
@require_auth(roles=['admin'])
|
||||||
def create_permission_setting():
|
def create_permission_setting():
|
||||||
"""
|
"""
|
||||||
@@ -59,7 +58,7 @@ def create_permission_setting():
|
|||||||
return jsonify(new_setting.to_dict()), 201
|
return jsonify(new_setting.to_dict()), 201
|
||||||
|
|
||||||
|
|
||||||
@setting_bp.route('/markdown/permission/<int:setting_id>', methods=['PUT', 'PATCH'])
|
@markdown_permission_setting_bp.route('/<int:setting_id>', methods=['PUT', 'PATCH'])
|
||||||
@require_auth(roles=['admin'])
|
@require_auth(roles=['admin'])
|
||||||
def update_permission_setting(setting_id):
|
def update_permission_setting(setting_id):
|
||||||
"""
|
"""
|
||||||
@@ -95,7 +94,7 @@ def update_permission_setting(setting_id):
|
|||||||
session.commit()
|
session.commit()
|
||||||
return jsonify(setting.to_dict()), 200
|
return jsonify(setting.to_dict()), 200
|
||||||
|
|
||||||
@setting_bp.route('/markdown/permission/<int:setting_id>', methods=['DELETE'])
|
@markdown_permission_setting_bp.route('/<int:setting_id>', methods=['DELETE'])
|
||||||
@require_auth(roles=['admin'])
|
@require_auth(roles=['admin'])
|
||||||
def delete_permission_setting(setting_id):
|
def delete_permission_setting(setting_id):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
from flask import jsonify, request
|
from flask import jsonify, request, Blueprint
|
||||||
import api
|
import api
|
||||||
from api import etag_response, limiter, require_auth
|
from api import etag_response, limiter, require_auth
|
||||||
from api.setting import setting_bp
|
|
||||||
from db import get_db
|
from db import get_db
|
||||||
from db.models.MarkdownTemplateSetting import MarkdownTemplateSetting
|
from db.models.MarkdownTemplateSetting import MarkdownTemplateSetting
|
||||||
|
|
||||||
|
|
||||||
@setting_bp.route('/markdown/template/', methods=['GET'])
|
markdown_template_setting_bp = Blueprint('markdown_template_setting', __name__, url_prefix='/api/setting/markdown/template')
|
||||||
|
@markdown_template_setting_bp.route('/', methods=['GET'])
|
||||||
@etag_response
|
@etag_response
|
||||||
def list_template_settings():
|
def list_template_settings():
|
||||||
"""
|
"""
|
||||||
@@ -24,7 +24,7 @@ def list_template_settings():
|
|||||||
settings = session.query(MarkdownTemplateSetting).all()
|
settings = session.query(MarkdownTemplateSetting).all()
|
||||||
return jsonify([s.to_dict() for s in settings]), 200
|
return jsonify([s.to_dict() for s in settings]), 200
|
||||||
|
|
||||||
@setting_bp.route('/markdown/template/<int:setting_id>/', methods=['GET'])
|
@markdown_template_setting_bp.route('/<int:setting_id>', methods=['GET'])
|
||||||
@etag_response
|
@etag_response
|
||||||
@limiter.limit(api.get_rate_limit)
|
@limiter.limit(api.get_rate_limit)
|
||||||
def get_template_setting(setting_id):
|
def get_template_setting(setting_id):
|
||||||
@@ -50,7 +50,7 @@ def get_template_setting(setting_id):
|
|||||||
return jsonify(setting.to_dict()), 200
|
return jsonify(setting.to_dict()), 200
|
||||||
|
|
||||||
|
|
||||||
@setting_bp.route('/markdown/template/', methods=['POST'])
|
@markdown_template_setting_bp.route('/', methods=['POST'])
|
||||||
@require_auth(roles=['admin'])
|
@require_auth(roles=['admin'])
|
||||||
def create_template_setting():
|
def create_template_setting():
|
||||||
"""
|
"""
|
||||||
@@ -77,7 +77,7 @@ def create_template_setting():
|
|||||||
return jsonify(new_setting.to_dict()), 201
|
return jsonify(new_setting.to_dict()), 201
|
||||||
|
|
||||||
|
|
||||||
@setting_bp.route('/markdown/template/<int:setting_id>', methods=['PUT', 'PATCH'])
|
@markdown_template_setting_bp.route('/<int:setting_id>', methods=['PUT', 'PATCH'])
|
||||||
@require_auth(roles=['admin'])
|
@require_auth(roles=['admin'])
|
||||||
def update_template_setting(setting_id):
|
def update_template_setting(setting_id):
|
||||||
"""
|
"""
|
||||||
@@ -113,7 +113,7 @@ def update_template_setting(setting_id):
|
|||||||
session.commit()
|
session.commit()
|
||||||
return jsonify(setting.to_dict()), 200
|
return jsonify(setting.to_dict()), 200
|
||||||
|
|
||||||
@setting_bp.route('/markdown/template/<int:setting_id>', methods=['DELETE'])
|
@markdown_template_setting_bp.route('/<int:setting_id>', methods=['DELETE'])
|
||||||
@require_auth(roles=['admin'])
|
@require_auth(roles=['admin'])
|
||||||
def delete_template_setting(setting_id):
|
def delete_template_setting(setting_id):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
from flask import jsonify, request
|
from flask import jsonify, request, Blueprint
|
||||||
|
|
||||||
import api
|
import api
|
||||||
from api import limiter, require_auth, etag_response
|
from api import limiter, require_auth, etag_response
|
||||||
from api.setting import setting_bp
|
|
||||||
from db import get_db
|
from db import get_db
|
||||||
from db.models.PathSetting import PathSetting
|
from db.models.PathSetting import PathSetting
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@setting_bp.route('/path/<int:setting_id>', methods=['GET'])
|
path_setting_bp = Blueprint('path_setting', __name__, url_prefix='/api/setting/path')
|
||||||
|
|
||||||
|
@path_setting_bp.route('/<int:setting_id>', methods=['GET'])
|
||||||
@limiter.limit(api.get_rate_limit)
|
@limiter.limit(api.get_rate_limit)
|
||||||
@etag_response
|
@etag_response
|
||||||
def get_path_setting(setting_id):
|
def get_path_setting(setting_id):
|
||||||
@@ -34,7 +35,7 @@ def get_path_setting(setting_id):
|
|||||||
return jsonify({}), 204
|
return jsonify({}), 204
|
||||||
return jsonify(setting.to_dict()), 200
|
return jsonify(setting.to_dict()), 200
|
||||||
|
|
||||||
@setting_bp.route('/path/', methods=['POST'])
|
@path_setting_bp.route('/', methods=['POST'])
|
||||||
@require_auth(roles=['admin'])
|
@require_auth(roles=['admin'])
|
||||||
def create_path_setting():
|
def create_path_setting():
|
||||||
"""
|
"""
|
||||||
@@ -71,7 +72,7 @@ def create_path_setting():
|
|||||||
return jsonify({"error": "failed to create setting"}), 500
|
return jsonify({"error": "failed to create setting"}), 500
|
||||||
|
|
||||||
|
|
||||||
@setting_bp.route('/path/<int:setting_id>', methods=['PUT', 'PATCH'])
|
@path_setting_bp.route('/<int:setting_id>', methods=['PUT', 'PATCH'])
|
||||||
@require_auth(roles=['admin'])
|
@require_auth(roles=['admin'])
|
||||||
def update_path_setting(setting_id):
|
def update_path_setting(setting_id):
|
||||||
"""
|
"""
|
||||||
@@ -108,7 +109,7 @@ def update_path_setting(setting_id):
|
|||||||
except Exception:
|
except Exception:
|
||||||
return jsonify({"error": "failed to update path setting"}), 500
|
return jsonify({"error": "failed to update path setting"}), 500
|
||||||
|
|
||||||
@setting_bp.route('/path/<int:setting_id>', methods=['DELETE'])
|
@path_setting_bp.route('/<int:setting_id>', methods=['DELETE'])
|
||||||
@require_auth(roles=['admin'])
|
@require_auth(roles=['admin'])
|
||||||
def delete_path_setting(setting_id):
|
def delete_path_setting(setting_id):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
from flask import jsonify, request
|
from flask import jsonify, request, Blueprint
|
||||||
from api import require_auth
|
from api import require_auth
|
||||||
from api.setting import setting_bp
|
|
||||||
from db import get_db
|
from db import get_db
|
||||||
from db.models.WebhookSetting import WebhookSetting
|
from db.models.WebhookSetting import WebhookSetting
|
||||||
|
|
||||||
|
path_webhook_setting_bp = Blueprint('path_webhook_setting', __name__, url_prefix='/api/setting/path/webhook')
|
||||||
@setting_bp.route('/path/webhook/', methods=['GET'])
|
@path_webhook_setting_bp.route('/', methods=['GET'])
|
||||||
@require_auth(roles=['admin'])
|
@require_auth(roles=['admin'])
|
||||||
def list_webhook_settings():
|
def list_webhook_settings():
|
||||||
"""
|
"""
|
||||||
@@ -25,7 +24,7 @@ def list_webhook_settings():
|
|||||||
return jsonify([s.to_dict() for s in settings]), 200
|
return jsonify([s.to_dict() for s in settings]), 200
|
||||||
|
|
||||||
|
|
||||||
@setting_bp.route('/path/webhook/<int:setting_id>', methods=['GET'])
|
@path_webhook_setting_bp.route('/<int:setting_id>', methods=['GET'])
|
||||||
@require_auth(roles=['admin'])
|
@require_auth(roles=['admin'])
|
||||||
def webhook_setting(setting_id):
|
def webhook_setting(setting_id):
|
||||||
"""
|
"""
|
||||||
@@ -51,7 +50,7 @@ def webhook_setting(setting_id):
|
|||||||
return jsonify(setting.to_dict()), 200
|
return jsonify(setting.to_dict()), 200
|
||||||
|
|
||||||
|
|
||||||
@setting_bp.route('/path/webhook/', methods=['POST'])
|
@path_webhook_setting_bp.route('/', methods=['POST'])
|
||||||
@require_auth(roles=['admin'])
|
@require_auth(roles=['admin'])
|
||||||
def create_webhook_setting():
|
def create_webhook_setting():
|
||||||
"""
|
"""
|
||||||
@@ -87,7 +86,7 @@ def create_webhook_setting():
|
|||||||
return jsonify(setting.to_dict()), 201
|
return jsonify(setting.to_dict()), 201
|
||||||
|
|
||||||
|
|
||||||
@setting_bp.route('/path/webhook/<int:setting_id>', methods=['PUT', 'PATCH'])
|
@path_webhook_setting_bp.route('/<int:setting_id>', methods=['PUT', 'PATCH'])
|
||||||
@require_auth(roles=['admin'])
|
@require_auth(roles=['admin'])
|
||||||
def update_webhook_setting(setting_id):
|
def update_webhook_setting(setting_id):
|
||||||
"""
|
"""
|
||||||
@@ -131,7 +130,7 @@ def update_webhook_setting(setting_id):
|
|||||||
return jsonify(setting.to_dict()), 200
|
return jsonify(setting.to_dict()), 200
|
||||||
|
|
||||||
|
|
||||||
@setting_bp.route('/path/webhook/<int:setting_id>', methods=['DELETE'])
|
@path_webhook_setting_bp.route('/<int:setting_id>', methods=['DELETE'])
|
||||||
@require_auth(roles=['admin'])
|
@require_auth(roles=['admin'])
|
||||||
def delete_webhook_setting(setting_id):
|
def delete_webhook_setting(setting_id):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
from flask import jsonify, request
|
from flask import jsonify, request, Blueprint
|
||||||
from api import etag_response, require_auth
|
from api import etag_response, require_auth
|
||||||
from api.template import template_bp
|
|
||||||
from db import get_db
|
from db import get_db
|
||||||
from db.models.MarkdownTemplate import MarkdownTemplate
|
from db.models.MarkdownTemplate import MarkdownTemplate
|
||||||
|
|
||||||
cached_templates = {}
|
cached_templates = {}
|
||||||
|
|
||||||
|
markdown_template_bp = Blueprint('markdown_template', __name__, url_prefix='/api/template/markdown')
|
||||||
|
|
||||||
def inflate_template(template):
|
def inflate_template(template):
|
||||||
for parameter in template.get('parameters'):
|
for parameter in template.get('parameters'):
|
||||||
if parameter.get('type', {}).get('base_type') == 'template':
|
if parameter.get('type', {}).get('base_type') == 'template':
|
||||||
@@ -19,7 +21,7 @@ def inflate_template(template):
|
|||||||
return template
|
return template
|
||||||
|
|
||||||
|
|
||||||
@template_bp.route('/markdown/<int:template_id>', methods=['GET'])
|
@markdown_template_bp.route('/<int:template_id>', methods=['GET'])
|
||||||
@etag_response
|
@etag_response
|
||||||
def get_markdown_template(template_id):
|
def get_markdown_template(template_id):
|
||||||
"""
|
"""
|
||||||
@@ -42,10 +44,9 @@ def get_markdown_template(template_id):
|
|||||||
template = session.query(MarkdownTemplate).get(template_id)
|
template = session.query(MarkdownTemplate).get(template_id)
|
||||||
if template is None:
|
if template is None:
|
||||||
return jsonify({}), 204
|
return jsonify({}), 204
|
||||||
print(inflate_template(template.to_dict()))
|
|
||||||
return jsonify(inflate_template(template.to_dict())), 200
|
return jsonify(inflate_template(template.to_dict())), 200
|
||||||
|
|
||||||
@template_bp.route('/markdown/', methods=['GET'])
|
@markdown_template_bp.route('/', methods=['GET'])
|
||||||
@etag_response
|
@etag_response
|
||||||
def get_markdown_templates():
|
def get_markdown_templates():
|
||||||
"""
|
"""
|
||||||
@@ -62,11 +63,10 @@ def get_markdown_templates():
|
|||||||
"""
|
"""
|
||||||
with get_db() as session:
|
with get_db() as session:
|
||||||
templates = session.query(MarkdownTemplate).all()
|
templates = session.query(MarkdownTemplate).all()
|
||||||
print(templates)
|
|
||||||
return jsonify([inflate_template(template.to_dict()) for template in templates]), 200
|
return jsonify([inflate_template(template.to_dict()) for template in templates]), 200
|
||||||
|
|
||||||
|
|
||||||
@template_bp.route('/markdown/', methods=['POST'])
|
@markdown_template_bp.route('/', methods=['POST'])
|
||||||
@require_auth(roles=['admin'])
|
@require_auth(roles=['admin'])
|
||||||
def create_markdown_template():
|
def create_markdown_template():
|
||||||
"""
|
"""
|
||||||
@@ -103,7 +103,7 @@ def create_markdown_template():
|
|||||||
return jsonify({"error": "failed to create markdown template"}), 400
|
return jsonify({"error": "failed to create markdown template"}), 400
|
||||||
|
|
||||||
|
|
||||||
@template_bp.route('/markdown/<int:template_id>', methods=['PUT', 'PATCH'])
|
@markdown_template_bp.route('/<int:template_id>', methods=['PUT', 'PATCH'])
|
||||||
@require_auth(roles=['admin'])
|
@require_auth(roles=['admin'])
|
||||||
def update_markdown_template(template_id):
|
def update_markdown_template(template_id):
|
||||||
"""
|
"""
|
||||||
@@ -142,7 +142,7 @@ def update_markdown_template(template_id):
|
|||||||
return jsonify(template.to_dict()), 200
|
return jsonify(template.to_dict()), 200
|
||||||
|
|
||||||
|
|
||||||
@template_bp.route('/markdown/<int:template_id>', methods=['DELETE'])
|
@markdown_template_bp.route('/<int:template_id>', methods=['DELETE'])
|
||||||
@require_auth(roles=['admin'])
|
@require_auth(roles=['admin'])
|
||||||
def delete_markdown_template(template_id):
|
def delete_markdown_template(template_id):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
from flask import jsonify, request
|
from flask import jsonify, request
|
||||||
|
from flask.sansio.blueprints import Blueprint
|
||||||
|
|
||||||
from api import etag_response, require_auth
|
from api import etag_response, require_auth
|
||||||
from api.template import template_bp
|
from api.template import template_bp
|
||||||
from db import get_db
|
from db import get_db
|
||||||
from db.models.PathTemplate import PathTemplate
|
from db.models.PathTemplate import PathTemplate
|
||||||
|
|
||||||
|
path_template_bp = Blueprint('path_template', __name__, url_prefix='/api/template/path')
|
||||||
@template_bp.route('/path/<int:template_id>', methods=['GET'])
|
@path_template_bp.route('/<int:template_id>', methods=['GET'])
|
||||||
@etag_response
|
@etag_response
|
||||||
def get_path_template(template_id):
|
def get_path_template(template_id):
|
||||||
"""
|
"""
|
||||||
@@ -30,7 +31,7 @@ def get_path_template(template_id):
|
|||||||
return jsonify({}), 204
|
return jsonify({}), 204
|
||||||
return jsonify(template.to_dict()), 200
|
return jsonify(template.to_dict()), 200
|
||||||
|
|
||||||
@template_bp.route('/path/', methods=['GET'])
|
@path_template_bp.route('/', methods=['GET'])
|
||||||
@etag_response
|
@etag_response
|
||||||
def get_path_templates():
|
def get_path_templates():
|
||||||
"""
|
"""
|
||||||
@@ -49,7 +50,7 @@ def get_path_templates():
|
|||||||
return jsonify([template.to_dict() for template in templates]), 200
|
return jsonify([template.to_dict() for template in templates]), 200
|
||||||
|
|
||||||
|
|
||||||
@template_bp.route('/path/', methods=['POST'])
|
@path_template_bp.route('/', methods=['POST'])
|
||||||
@require_auth(roles=['admin'])
|
@require_auth(roles=['admin'])
|
||||||
def create_path_template():
|
def create_path_template():
|
||||||
"""
|
"""
|
||||||
@@ -84,7 +85,7 @@ def create_path_template():
|
|||||||
return jsonify({"error": "failed to create path template"}), 400
|
return jsonify({"error": "failed to create path template"}), 400
|
||||||
|
|
||||||
|
|
||||||
@template_bp.route('/path/<int:template_id>', methods=['PUT', 'PATCH'])
|
@path_template_bp.route('/<int:template_id>', methods=['PUT', 'PATCH'])
|
||||||
@require_auth(roles=['admin'])
|
@require_auth(roles=['admin'])
|
||||||
def update_path_template(template_id):
|
def update_path_template(template_id):
|
||||||
"""
|
"""
|
||||||
@@ -118,7 +119,7 @@ def update_path_template(template_id):
|
|||||||
return jsonify(template.to_dict()), 200
|
return jsonify(template.to_dict()), 200
|
||||||
|
|
||||||
|
|
||||||
@template_bp.route('/path/<int:template_id>', methods=['DELETE'])
|
@path_template_bp.route('/<int:template_id>', methods=['DELETE'])
|
||||||
@require_auth(roles=['admin'])
|
@require_auth(roles=['admin'])
|
||||||
def delete_path_template(template_id):
|
def delete_path_template(template_id):
|
||||||
"""
|
"""
|
||||||
|
|||||||
8
app.py
8
app.py
@@ -1,4 +1,6 @@
|
|||||||
from pprint import pprint
|
from pprint import pprint
|
||||||
|
|
||||||
|
from events.WebhookEventHandlers import register_all_webhook_event_handlers
|
||||||
from logging_handlers.DatabaseLogHandler import DatabaseLogHandler
|
from logging_handlers.DatabaseLogHandler import DatabaseLogHandler
|
||||||
from api import limiter
|
from api import limiter
|
||||||
from flask import Flask, request
|
from flask import Flask, request
|
||||||
@@ -33,7 +35,8 @@ CORS(app, resources={
|
|||||||
r"https?://localhost:\d+",
|
r"https?://localhost:\d+",
|
||||||
r"https?://127\.0\.0\.1:\d+",
|
r"https?://127\.0\.0\.1:\d+",
|
||||||
r"https?://localhost"
|
r"https?://localhost"
|
||||||
]
|
],
|
||||||
|
"supports_credentials": True
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
expose_headers=['Content-Disposition']
|
expose_headers=['Content-Disposition']
|
||||||
@@ -42,7 +45,7 @@ CORS(app, resources={
|
|||||||
limiter.init_app(app)
|
limiter.init_app(app)
|
||||||
|
|
||||||
api.register_blueprints(app)
|
api.register_blueprints(app)
|
||||||
|
register_all_webhook_event_handlers()
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def log_request():
|
def log_request():
|
||||||
if request.path.startswith("/api/log"):
|
if request.path.startswith("/api/log"):
|
||||||
@@ -52,5 +55,6 @@ def log_request():
|
|||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
api.init_rate_limits(app)
|
api.init_rate_limits(app)
|
||||||
|
print("env")
|
||||||
pprint(env_provider.summerize())
|
pprint(env_provider.summerize())
|
||||||
app.run(host='0.0.0.0', port=5000, debug=True, use_reloader=True)
|
app.run(host='0.0.0.0', port=5000, debug=True, use_reloader=True)
|
||||||
|
|||||||
25
db/models/APIKey.py
Normal file
25
db/models/APIKey.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from datetime import datetime, timedelta, UTC
|
||||||
|
from sqlalchemy import Column, String, DateTime, Boolean, JSON
|
||||||
|
from db.models import Base
|
||||||
|
|
||||||
|
class APIKey(Base):
|
||||||
|
__tablename__ = 'apikey'
|
||||||
|
|
||||||
|
key = Column(String(64), primary_key=True)
|
||||||
|
name = Column(String(255), nullable=False)
|
||||||
|
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||||
|
last_used_at = Column(DateTime)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
roles = Column(JSON, nullable=False, default=list)
|
||||||
|
expire = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC) + timedelta(days=15))
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"key": self.key,
|
||||||
|
"name": self.name,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
|
"last_used_at": self.last_used_at.isoformat() if self.last_used_at else None,
|
||||||
|
"is_active": self.is_active,
|
||||||
|
"roles": self.roles,
|
||||||
|
"expire": self.expire.isoformat() if self.expire else None
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ class Path(Base):
|
|||||||
name = Column(String(50), nullable=False)
|
name = Column(String(50), nullable=False)
|
||||||
parent_id = Column(Integer, ForeignKey("path.id"), nullable=True)
|
parent_id = Column(Integer, ForeignKey("path.id"), nullable=True)
|
||||||
order = Column(String(36), default=lambda: str(uuid.uuid4()))
|
order = Column(String(36), default=lambda: str(uuid.uuid4()))
|
||||||
setting_id = Column(Integer, ForeignKey("path_settings.id"), nullable=True)
|
setting_id = Column(Integer, ForeignKey("path_setting.id"), nullable=True)
|
||||||
__table_args__ = (UniqueConstraint("parent_id", "name", name="unique_parent_id_name"), )
|
__table_args__ = (UniqueConstraint("parent_id", "name", name="unique_parent_id_name"), )
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from db.models import Base
|
|||||||
|
|
||||||
|
|
||||||
class PathSetting(Base):
|
class PathSetting(Base):
|
||||||
__tablename__ = 'path_settings'
|
__tablename__ = 'path_setting'
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
webhook_setting_id = Column(Integer, ForeignKey('webhook_setting.id'), nullable=True)
|
webhook_setting_id = Column(Integer, ForeignKey('webhook_setting.id'), nullable=True)
|
||||||
template_setting_id = Column(Integer, ForeignKey('path_template.id'), nullable=True)
|
template_setting_id = Column(Integer, ForeignKey('path_template.id'), nullable=True)
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from db.models.Path import Path
|
from db.models.Path import Path
|
||||||
|
from db.models.PathSetting import PathSetting
|
||||||
from db.models.Webhook import Webhook
|
from db.models.Webhook import Webhook
|
||||||
from db.models.WebhookSetting import WebhookSetting
|
from db.models.WebhookSetting import WebhookSetting
|
||||||
|
from events import MARKDOWN_CREATED_EVENT, MARKDOWN_UPDATED_EVENT, MARKDOWN_DELETED_EVENT, PATH_CREATED_EVENT, \
|
||||||
|
PATH_UPDATED_EVENT, PATH_DELETED_EVENT
|
||||||
import abc
|
import abc
|
||||||
import importlib
|
import importlib
|
||||||
import json
|
import json
|
||||||
@@ -9,6 +12,15 @@ import pkgutil
|
|||||||
import requests
|
import requests
|
||||||
import db
|
import db
|
||||||
|
|
||||||
|
|
||||||
|
event_type_map = {
|
||||||
|
MARKDOWN_CREATED_EVENT: 'markdown_created_event',
|
||||||
|
MARKDOWN_UPDATED_EVENT: 'markdown_updated_event',
|
||||||
|
MARKDOWN_DELETED_EVENT: 'markdown_deleted_event',
|
||||||
|
PATH_CREATED_EVENT: 'path_created_event',
|
||||||
|
PATH_UPDATED_EVENT: 'path_updated_event',
|
||||||
|
PATH_DELETED_EVENT: 'path_deleted_event',
|
||||||
|
}
|
||||||
class WebhookEventHandler(abc.ABC):
|
class WebhookEventHandler(abc.ABC):
|
||||||
def __init__(self, event_type=0):
|
def __init__(self, event_type=0):
|
||||||
self.event_type = event_type
|
self.event_type = event_type
|
||||||
@@ -17,20 +29,21 @@ class WebhookEventHandler(abc.ABC):
|
|||||||
def get_path_id(self, payload):
|
def get_path_id(self, payload):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def __call__(self, **kwargs):
|
def __call__(self, *args, **kwargs):
|
||||||
payload = kwargs['payload']
|
payload = kwargs['payload']
|
||||||
path_id = self.get_path_id(payload)
|
path_id = self.get_path_id(payload)
|
||||||
with db.get_db() as session:
|
with db.get_db() as session:
|
||||||
setting = self.get_setting(session, path_id)
|
setting = self.get_setting(session, path_id)
|
||||||
if setting is None:
|
if setting is None:
|
||||||
return
|
return
|
||||||
headers = {'Content-Type': 'application/json'}
|
headers = {'Content-Type': 'application/json', 'x-alchegos-event': event_type_map[self.event_type]}
|
||||||
if setting["additional_headers"] is not None:
|
if setting.get("additional_header", None) is not None:
|
||||||
headers.update(json.loads(setting["additional_headers"]))
|
headers.update(json.loads(setting["additional_header"]))
|
||||||
|
body = json.dumps(payload, default=str)
|
||||||
try:
|
try:
|
||||||
response = requests.post(setting["webhook_url"], json=payload, headers=headers, timeout=5)
|
response = requests.post(setting["webhook_url"], data=body, headers=headers, timeout=5)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
except requests.RequestException as e:
|
except Exception as e:
|
||||||
print(e)
|
print(e)
|
||||||
|
|
||||||
def get_setting(self, session: Session, path_id):
|
def get_setting(self, session: Session, path_id):
|
||||||
@@ -39,15 +52,22 @@ class WebhookEventHandler(abc.ABC):
|
|||||||
if path is None:
|
if path is None:
|
||||||
return None
|
return None
|
||||||
p = path.to_dict()
|
p = path.to_dict()
|
||||||
webhook_setting = session.query(WebhookSetting).filter(WebhookSetting.path_id == path_id).first()
|
path_setting = session.query(PathSetting).get(path.setting_id)
|
||||||
|
if path_setting is None:
|
||||||
|
return None
|
||||||
|
webhook_setting = session.query(WebhookSetting).get(path_setting.webhook_setting_id)
|
||||||
|
|
||||||
if webhook_setting is None and p["parent_id"] != 1:
|
if webhook_setting is None and p["parent_id"] != 1:
|
||||||
return self.get_setting(session, p["parent_id"])
|
return self.get_setting(session, p["parent_id"])
|
||||||
|
|
||||||
|
# Check if webhook_setting is still None (e.g., when parent_id == 1 or no parent found)
|
||||||
|
if webhook_setting is None:
|
||||||
|
return None
|
||||||
|
|
||||||
setting = webhook_setting.to_dict()
|
setting = webhook_setting.to_dict()
|
||||||
if not setting["enabled"] or setting["on_events"] & self.event_type == 0:
|
if not setting["enabled"] or setting["on_events"] & self.event_type == 0:
|
||||||
return None
|
return None
|
||||||
webhook = session.query(Webhook).filter(Webhook.id == webhook_setting.webhook_id).first()
|
webhook = session.query(Webhook).get(webhook_setting.webhook_id)
|
||||||
if webhook is None:
|
if webhook is None:
|
||||||
return None
|
return None
|
||||||
setting["webhook_url"] = webhook.to_dict()["hook_url"]
|
setting["webhook_url"] = webhook.to_dict()["hook_url"]
|
||||||
|
|||||||
9
req2.txt
9
req2.txt
@@ -1,9 +0,0 @@
|
|||||||
cryptography==44.0.0
|
|
||||||
Flask==3.1.0
|
|
||||||
Flask_Cors==5.0.0
|
|
||||||
Flask_Limiter==3.9.2
|
|
||||||
PyJWT==2.10.1
|
|
||||||
PyJWT==2.10.1
|
|
||||||
python-dotenv==1.0.1
|
|
||||||
Requests==2.32.3
|
|
||||||
SQLAlchemy==2.0.36
|
|
||||||
5
requirements-test.txt
Normal file
5
requirements-test.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Test dependencies
|
||||||
|
pytest==7.4.0
|
||||||
|
pytest-flask==1.2.0
|
||||||
|
pytest-cov==4.1.0
|
||||||
|
pytest-mock==3.11.1
|
||||||
@@ -34,3 +34,5 @@ typing_extensions==4.12.2
|
|||||||
urllib3==2.2.3
|
urllib3==2.2.3
|
||||||
Werkzeug==3.1.3
|
Werkzeug==3.1.3
|
||||||
wrapt==1.17.0
|
wrapt==1.17.0
|
||||||
|
|
||||||
|
pytest~=8.3.5
|
||||||
Reference in New Issue
Block a user