add: webhook

This commit is contained in:
h z
2025-03-17 13:54:53 +00:00
parent acb1e2260f
commit 864b78641b
23 changed files with 473 additions and 43 deletions

View File

@@ -41,20 +41,19 @@ def get_jwks():
def get_public_key_for_kid(kid):
global _public_key_cache
if kid in _public_key_cache:
return _public_key_cache[kid]
jwks = get_jwks()
res = []
for key_data in jwks["keys"]:
if key_data["kid"] == kid and key_data["use"] == "sig" and key_data["alg"] == "RS256" and key_data["kty"] == "RSA":
x5c = key_data["x5c"][0]
pem_public_key = x5c_to_public_key(x5c)
_public_key_cache[kid] = pem_public_key
res.append(pem_public_key)
if len(res) > 0:
print(len(res))
return res[0]
with _lock:
if kid in _public_key_cache:
return _public_key_cache[kid]
jwks = get_jwks()
res = []
for key_data in jwks["keys"]:
if key_data["kid"] == kid and key_data["use"] == "sig" and key_data["alg"] == "RS256" and key_data["kty"] == "RSA":
x5c = key_data["x5c"][0]
pem_public_key = x5c_to_public_key(x5c)
_public_key_cache[kid] = pem_public_key
res.append(pem_public_key)
if len(res) > 0:
return res[0]
return None

View File

@@ -41,3 +41,4 @@ def create_log():
extra = data.get('extra', None)
log_entry = Log(level=level, message=message, application=application, extra=extra)
insert_log(log_entry)
return jsonify({"message": "log created"}), 201

View File

@@ -1,12 +1,13 @@
from flask import Blueprint, request, jsonify
from sqlalchemy import or_
import api
from api import limiter
from api import require_auth, etag_response
from contexts.RequestContext import RequestContext
from db import get_db
from db.models.Markdown import Markdown
from events import markdown_created, markdown_updated, markdown_deleted
import api
import logging
from api import limiter
logger = logging.getLogger(__name__)
markdown_bp = Blueprint('markdown', __name__, url_prefix='/api/markdown')
@@ -79,6 +80,7 @@ def create_markdown():
return jsonify({"error": "duplicate shortcut"}), 400
session.add(new_markdown)
session.commit()
markdown_created.send(None, new_markdown.to_dict())
return jsonify(new_markdown.to_dict()), 201
except Exception as e:
logger.error(f"failed to create markdown: {e}")
@@ -118,6 +120,7 @@ def update_markdown(markdown_id):
if 'shortcut' in data:
markdown.shortcut = data.get('shortcut')
session.commit()
markdown_updated.send(None, markdown.to_dict())
return jsonify(markdown.to_dict()), 200
@markdown_bp.route('/<int:markdown_id>', methods=['DELETE'])
@@ -130,8 +133,10 @@ def delete_markdown(markdown_id):
logger.error(f"failed to delete markdown: {markdown_id}")
errno = RequestContext.get_error_id()
return jsonify({"error": f"file not found - {errno}"}), 404
md = markdown.to_dict()
session.delete(markdown)
session.commit()
markdown_deleted.send(None, md)
return jsonify({"message": "deleted"}), 200
@@ -150,6 +155,7 @@ def move_forward(markdown_id):
previous_markdown = siblings[current_index - 1]
markdown.order, previous_markdown.order = previous_markdown.order, markdown.order
session.commit()
markdown_updated.send(None, markdown.to_dict())
return jsonify(markdown.to_dict()), 200
@@ -167,8 +173,9 @@ def move_backward(markdown_id):
return jsonify({"error": "already at the last position"}), 400
next_markdown = siblings[current_index + 1]
markdown.order, next_markdown.order = next_markdown.order, next_markdown.order
markdown.order, next_markdown.order = next_markdown.order, markdown.order
session.commit()
markdown_updated.send(None, markdown.to_dict())
return jsonify(markdown.to_dict()), 200
@markdown_bp.route('/search/<string:keyword>', methods=['GET'])

View File

@@ -7,6 +7,9 @@ from db.models.Markdown import Markdown
from db.models.Path import Path
from api import limiter
import logging
from events import path_created, path_updated, path_deleted
logger = logging.getLogger(__name__)
path_bp = Blueprint('path', __name__, url_prefix='/api/path')
@@ -52,6 +55,7 @@ def create_path():
new_path = Path(name=data['name'], parent_id=data['parent_id'])
session.add(new_path)
session.commit()
path_created.send(None, new_path.to_dict())
return jsonify(new_path.to_dict()), 201
@path_bp.route('/<int:path_id>', methods=['PUT'])
@@ -70,6 +74,7 @@ def update_path(path_id):
path.name = data['name']
path.parent_id = data['parent_id']
session.commit()
path_updated.send(None, path.to_dict())
return jsonify(path.to_dict()), 200
@path_bp.route('/<int:path_id>', methods=['PATCH'])
@@ -91,6 +96,7 @@ def patch_path(path_id):
path.name = updated_name
path.parent_id = updated_parent_id
session.commit()
path_updated.send(None, path.to_dict())
return jsonify(path.to_dict()), 200
@@ -106,8 +112,10 @@ def delete_path(path_id):
return jsonify({"error": "can not delete non empty path"}), 409
if session.query(Markdown).filter_by(path_id=path_id).first():
return jsonify({"error": "can not delete non empty path"}), 409
pth = path.to_dict()
session.delete(path)
session.commit()
path_deleted.send(None, pth)
return jsonify({"message": "path deleted"}), 200
@@ -126,6 +134,7 @@ def move_forward(path_id):
previous_path = siblings[current_index - 1]
path.order, previous_path.order = previous_path.order, path.order
session.commit()
path_updated.send(None, path.to_dict())
return jsonify(path.to_dict()), 200
@@ -145,5 +154,6 @@ def move_backward(path_id):
next_path = siblings[current_index + 1]
path.order, next_path.order = next_path.order, path.order
session.commit()
path_updated.send(None, path.to_dict())
return jsonify(path.to_dict()), 200

152
api/webhook.py Normal file
View File

@@ -0,0 +1,152 @@
from flask import Blueprint, jsonify, request
from api import require_auth, limiter
from db import get_db
from db.models.Webhook import Webhook
from db.models.WebhookSetting import WebhookSetting
webhook_bp = Blueprint('webhook', __name__, url_prefix='/api/webhook')
@webhook_bp.route('/', methods=['GET'])
@require_auth(roles=['admin'])
def list_webhooks():
with get_db() as session:
hooks = session.query(Webhook).all()
return jsonify([h.to_dict() for h in hooks]), 200
@webhook_bp.route('/', methods=['POST'])
@require_auth(['admin'])
def create_webhook():
data = request.json
hook_url = data.get('hook_url')
if not hook_url:
return jsonify({'error': 'hook_url required'}), 400
with get_db() as session:
existing = session.query(Webhook).filter_by(hook_url=hook_url).first()
if existing:
return jsonify({'error': 'Webhook URL already exists'}), 409
webhook = Webhook(hook_url=hook_url)
session.add(webhook)
session.commit()
return jsonify(webhook.to_dict()), 201
@webhook_bp.route('/<int:webhook_id>', methods=['PUT', 'PATCH'])
@require_auth(roles=['admin'])
def update_webhook(webhook_id):
data = request.json
if 'hook_url' not in data:
return jsonify({'error': 'hook_url is required'}), 400
with get_db() as session:
webhook = session.query(Webhook).get(webhook_id)
if not webhook:
return jsonify({'error': 'Webhook not found'}), 404
existing = session.query(Webhook).filter_by(hook_url=data['hook_url']).filter(Webhook.id != webhook_id).first()
if existing:
return jsonify({'error': 'Webhook URL already exists'}), 409
webhook.hook_url = data['hook_url']
session.commit()
return jsonify(webhook.to_dict()), 200
@webhook_bp.route('/<int:webhook_id>', methods=['DELETE'])
@require_auth(roles=['admin'])
def delete_webhook(webhook_id):
with get_db() as session:
webhook = session.query(Webhook).get(webhook_id)
if not webhook:
return jsonify({'error': 'Webhook not found'}), 404
session.delete(webhook)
session.commit()
return jsonify({'message': 'Webhook deleted'}), 200
@webhook_bp.route('/setting/', methods=['GET'])
@require_auth(roles=['admin'])
def list_webhook_settings():
with get_db() as session:
settings = session.query(WebhookSetting).all()
return jsonify([s.to_dict() for s in settings]), 200
@webhook_bp.route('/setting/<int:setting_id>', methods=['GET'])
@require_auth(roles=['admin'])
def webhook_setting(setting_id):
with get_db() as session:
setting = session.query(WebhookSetting).filter(WebhookSetting.id == setting_id).first()
if not setting:
return jsonify({'info': 'Webhook setting not found'}), 204
return jsonify(setting.to_dict()), 200
@webhook_bp.route('/setting/path/<int:path_id>', methods=['GET'])
@require_auth(roles=['admin'])
def webhook_setting_by_path(path_id):
with get_db() as session:
setting = session.query(WebhookSetting).filter(WebhookSetting.path_id == path_id).first()
if not setting:
return jsonify({'info': 'Webhook setting not found'}), 204
return jsonify(setting.to_dict()), 200
@webhook_bp.route('/setting/', methods=['POST'])
@require_auth(roles=['admin'])
def create_webhook_setting():
data = request.json
print(data)
required = ['path_id', 'webhook_id']
for key in required:
if key not in data:
return jsonify({'error': 'Required field missing'}), 400
with get_db() as session:
setting = WebhookSetting(
path_id=data.get('path_id'),
webhook_id=data.get('webhook_id'),
recursive=data.get('recursive', False),
additional_header=data.get('additional_header', ''),
enabled=data.get('enabled', True),
on_events=data.get('on_events', 1),
)
session.add(setting)
session.commit()
return jsonify(setting.to_dict()), 201
@webhook_bp.route('/setting/<int:setting_id>', methods=['PUT', 'PATCH'])
@require_auth(roles=['admin'])
def update_webhook_setting(setting_id):
data = request.json
with get_db() as session:
setting = session.query(WebhookSetting).get(setting_id)
if not setting:
return jsonify({'error': 'Webhook setting not found'}), 404
if 'recursive' in data:
setting.recursive = data['recursive']
if 'additional_header' in data:
setting.additional_header = data['additional_header']
if 'enabled' in data:
setting.enabled = data['enabled']
if 'on_events' in data:
setting.on_events = data['on_events']
session.commit()
return jsonify(setting.to_dict()), 200
@webhook_bp.route('/setting/<int:setting_id>', methods=['DELETE'])
@require_auth(roles=['admin'])
def delete_webhook_setting(setting_id):
with get_db() as session:
setting = session.query(WebhookSetting).get(setting_id)
if not setting:
return jsonify({'error': 'Webhook setting not found'}), 404
session.delete(setting)
session.commit()
return jsonify({'message': 'Webhook setting deleted'}), 200

View File

@@ -1,5 +1,3 @@
import os
import subprocess
from contextlib import contextmanager
from sqlalchemy.schema import CreateTable
@@ -21,15 +19,6 @@ def get_db():
finally:
db.close()
# def dump_db():
# try:
# os.environ['MYSQL_PWD'] = DB_PASSWORD
# dump_cmd = f"mysqldump --no-tablespaces -h {DB_HOST} -P {DB_PORT} -u {DB_USER} {DB_NAME} > /app/dump/db_dump.sql"
# subprocess.run(dump_cmd, shell=True, check=True)
# except subprocess.CalledProcessError as e:
# print(f"Failed to dump database: {e}")
# raise e
def clear_db():
with engine.connect() as conn:
conn.execute(text("SET FOREIGN_KEY_CHECKS = 0;"))
@@ -60,9 +49,9 @@ def init_payload():
for model in table_models:
print(str(CreateTable(model.__table__)))
print(f"MODEL -- {model}, {hasattr(model, '__pay_load__')}")
if hasattr(model, "__pay_load__"):
payload = model.__pay_load__[ENVIRONMENT]
print(f"MODEL -- {model}, {hasattr(model, '__payload__')}")
if hasattr(model, "__payload__"):
payload = model.__payload__[ENVIRONMENT]
print(f"- - [ - ] hasattr, {ENVIRONMENT} - {payload}")
stmt = insert(model.__table__).values(payload).prefix_with("IGNORE")
print(f"- - [ - ] {stmt}\n")
@@ -73,11 +62,6 @@ def init_payload():
def setup_db():
if DB_SCHEMA_UPDATED:
# try:
# dump_db()
# print("[ x ] db dumped")
# except Exception as e:
# print(f"[ x ] Failed to dump database: {e}")
clear_db()
print("[ x ] db cleared")
create_all()

View File

@@ -9,7 +9,7 @@ class Markdown(Base):
id = Column(Integer, primary_key=True)
title = Column(String(255), nullable=False)
content = Column(Text, nullable=False)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
created_at = Column(DateTime, default=datetime.datetime.now(datetime.UTC))
path_id = Column(Integer, ForeignKey('path.id'), nullable=False)
order = Column(String(36), default=lambda: str(uuid.uuid4()))
shortcut = Column(String(36), default="")
@@ -23,7 +23,7 @@ class Markdown(Base):
'order': self.order,
'shortcut': self.shortcut,
}
__pay_load__ = {
__payload__ = {
'dev': [
{'id': 1, 'title': 'index', 'content': ' ', 'created_at': datetime.datetime.utcnow, 'path_id': 1 },
],

View File

@@ -19,7 +19,7 @@ class Path(Base):
"order": self.order,
}
__pay_load__ = {
__payload__ = {
'dev': [
{'id': 1, 'name': '', 'parent_id': None},
{'id': 2, 'name': 'test', 'parent_id': 1},

16
db/models/Webhook.py Normal file
View File

@@ -0,0 +1,16 @@
from sqlalchemy import Column, String, Integer, ForeignKey, UniqueConstraint, text, Boolean
from db.models import Base
class Webhook(Base):
__tablename__ = 'webhook'
id = Column(Integer, primary_key=True, autoincrement=True)
hook_url = Column(String(100), nullable=False)
__table_args__ = (UniqueConstraint('hook_url'),)
def to_dict(self):
return {
"id": self.id,
"hook_url": self.hook_url
}

View File

@@ -0,0 +1,26 @@
from sqlalchemy import Column, String, Integer, ForeignKey, UniqueConstraint, Boolean
from db.models import Base
class WebhookSetting(Base):
__tablename__ = 'webhook_setting'
id = Column(Integer, primary_key=True, autoincrement=True)
path_id = Column(Integer, ForeignKey('path.id'), nullable=False)
webhook_id = Column(Integer, ForeignKey('webhook.id'), nullable=False)
recursive = Column(Boolean, default=False)
additional_header = Column(String(500), nullable=True)
enabled = Column(Boolean, default=True)
on_events = Column(Integer, default=0)
__table_args__ = (UniqueConstraint('webhook_id', 'path_id', name='_webhook_path_uc'),)
def to_dict(self):
return {
"id": self.id,
"path_id": self.path_id,
"webhook_id": self.webhook_id,
"recursive": self.recursive,
"additional_header": self.additional_header,
"enabled": self.enabled,
"on_events": self.on_events,
}

View File

@@ -1,8 +1,6 @@
#db/models/__init__.py
import pkgutil
import importlib
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import declarative_base
Base = declarative_base()
package_name = "db.models"

View File

@@ -1,4 +1,3 @@
#db/utils.py
from db import get_db
def insert_log(log_entry):

View File

@@ -0,0 +1,20 @@
from events import MARKDOWN_CREATED_EVENT, markdown_created
from events.WebhookEventHandlers import auto_instantiate
from events.WebhookEventHandlers.MarkdownWebhookEventHandlers import MarkdownWebhookEventHandler
from misc import Singleton
@auto_instantiate
class MarkdownCreatedWebhookEventHandler(MarkdownWebhookEventHandler, Singleton):
def __init__(self):
if getattr(self, "_initialized", False):
return
super().__init__(MARKDOWN_CREATED_EVENT)
markdown_created.connect(self)
self._initialized = True

View File

@@ -0,0 +1,15 @@
from events import MARKDOWN_DELETED_EVENT, markdown_deleted
from events.WebhookEventHandlers import auto_instantiate
from events.WebhookEventHandlers.MarkdownWebhookEventHandlers import MarkdownWebhookEventHandler
from misc import Singleton
@auto_instantiate
class MarkdownDeletedWebhookEventHandler(MarkdownWebhookEventHandler, Singleton):
def __init__(self):
if getattr(self, "_initialized", False):
return
super().__init__(MARKDOWN_DELETED_EVENT)
markdown_deleted.connect(self)
self._initialized = True

View File

@@ -0,0 +1,20 @@
from events import MARKDOWN_UPDATED_EVENT, markdown_updated
from events.WebhookEventHandlers import auto_instantiate
from events.WebhookEventHandlers.MarkdownWebhookEventHandlers import MarkdownWebhookEventHandler
from misc import Singleton
@auto_instantiate
class MarkdownUpdatedWebhookEventHandler(MarkdownWebhookEventHandler, Singleton):
def __init__(self):
if getattr(self, "_initialized", False):
return
super().__init__(MARKDOWN_UPDATED_EVENT)
markdown_updated.connect(self)
self._initialized = True

View File

@@ -0,0 +1,9 @@
from events.WebhookEventHandlers import WebhookEventHandler
class MarkdownWebhookEventHandler(WebhookEventHandler):
def __init__(self, event_type):
super().__init__(event_type)
def get_path_id(self, payload):
return payload["path_id"]

View File

@@ -0,0 +1,26 @@
from events import PATH_CREATED_EVENT, path_created
from events.WebhookEventHandlers import auto_instantiate
from events.WebhookEventHandlers.PathWebhookEventHandlers import PathWebhookEventHandler
from misc import Singleton
@auto_instantiate
class PathCreatedWebhookEventHandler(PathWebhookEventHandler,Singleton):
_instance = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super(PathCreatedWebhookEventHandler, cls).__new__(cls)
return cls._instance
def __init__(self):
if getattr(self, "_initialized", False):
return
super().__init__(PATH_CREATED_EVENT)
path_created.connect(self)
self._initialized = True

View File

@@ -0,0 +1,20 @@
from events import PATH_DELETED_EVENT, path_deleted
from events.WebhookEventHandlers import auto_instantiate
from events.WebhookEventHandlers.PathWebhookEventHandlers import PathWebhookEventHandler
from misc import Singleton
@auto_instantiate
class PathDeletedWebhookEventHandler(PathWebhookEventHandler, Singleton):
_instance = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super(PathDeletedWebhookEventHandler, cls).__new__(cls)
return cls._instance
def __init__(self):
if getattr(self, "_initialized", False):
return
super().__init__(PATH_DELETED_EVENT)
path_deleted.connect(self)
self._initialized = True

View File

@@ -0,0 +1,26 @@
from events import PATH_UPDATED_EVENT, path_updated
from events.WebhookEventHandlers import auto_instantiate
from events.WebhookEventHandlers.PathWebhookEventHandlers import PathWebhookEventHandler
from misc import Singleton
@auto_instantiate
class PathUpdatedWebhookEventHandler(PathWebhookEventHandler, Singleton):
_instance = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super(PathUpdatedWebhookEventHandler, cls).__new__(cls)
return cls._instance
def __init__(self):
if getattr(self, "_initialized", False):
return
super().__init__(PATH_UPDATED_EVENT)
path_updated.connect(self)
self._initialized = True

View File

@@ -0,0 +1,9 @@
from events.WebhookEventHandlers import WebhookEventHandler
class PathWebhookEventHandler(WebhookEventHandler):
def __init__(self, event_type):
super().__init__(event_type)
def get_path_id(self, payload):
return payload["id"]

View File

@@ -0,0 +1,69 @@
from sqlalchemy.orm import Session
from db.models.Path import Path
from db.models.Webhook import Webhook
from db.models.WebhookSetting import WebhookSetting
import abc
import importlib
import json
import pkgutil
import requests
import db
class WebhookEventHandler(abc.ABC):
def __init__(self, event_type=0):
self.event_type = event_type
@abc.abstractmethod
def get_path_id(self, payload):
pass
def __call__(self, payload):
path_id = self.get_path_id(payload)
with db.get_db() as session:
setting = self.get_setting(session, path_id)
if setting is None:
return
headers = {'Content-Type': 'application/json'}
if setting["additional_headers"] is not None:
headers.update(json.loads(setting["additional_headers"]))
try:
response = requests.post(setting["webhook_url"], json=payload, headers=headers, timeout=5)
response.raise_for_status()
except requests.RequestException as e:
print(e)
def get_setting(self, session: Session, path_id):
path = session.query(Path).filter(Path.id == path_id).first()
if path is None:
return None
p = path.to_dict()
webhook_setting = session.query(WebhookSetting).filter(WebhookSetting.path_id == path_id).first()
if webhook_setting is None and p["parent_id"] != 1:
return self.get_setting(session, p["parent_id"])
setting = webhook_setting.to_dict()
if not setting["enabled"] or setting["on_events"] & self.event_type == 0:
return None
webhook = session.query(Webhook).filter(Webhook.id == webhook_setting.webhook_id).first()
if webhook is None:
return None
setting["webhook_url"] = webhook.to_dict()["hook_url"]
return setting
_auto_instantiate_classes = set()
def auto_instantiate(cls):
_auto_instantiate_classes.add(cls)
return cls
def register_all_webhook_event_handlers():
package = __name__
package_path = __path__
for finder, name, ispkg in pkgutil.walk_packages(package_path, package+"."):
importlib.import_module(name)
for cls in _auto_instantiate_classes:
cls()

17
events/__init__.py Normal file
View File

@@ -0,0 +1,17 @@
from blinker import Namespace
signals = Namespace()
MARKDOWN_CREATED_EVENT = 1
MARKDOWN_UPDATED_EVENT = 2
MARKDOWN_DELETED_EVENT = 4
PATH_CREATED_EVENT = 8
PATH_UPDATED_EVENT = 16
PATH_DELETED_EVENT = 32
markdown_created = signals.signal('markdown_created')
markdown_updated = signals.signal('markdown_updated')
markdown_deleted = signals.signal('markdown_deleted')
path_created = signals.signal('path_created')
path_updated = signals.signal('path_updated')
path_deleted = signals.signal('path_deleted')

7
misc/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
class Singleton:
_instances = {}
def __new__(cls, *args, **kwargs):
if cls not in cls._instances:
cls._instances[cls] = super(Singleton, cls).__new__(cls)
return cls._instances[cls]