From 4c6571d2f85ee6b0ad63a3c55e44bd87b4ff12e2 Mon Sep 17 00:00:00 2001 From: Anthony Donlon Date: Thu, 20 Nov 2025 07:24:19 +0800 Subject: [PATCH] add simple editor UI and backend --- .gitignore | 4 + cloudflare_error_page/__init__.py | 8 +- cloudflare_error_page/templates/error.html | 13 +- editor/resources/index.html | 783 +++++++++++++++++++++ editor/resources/template.ejs | 118 ++++ editor/server/__init__.py | 106 +++ editor/server/editor.py | 21 + editor/server/examples.py | 58 ++ editor/server/models.py | 19 + editor/server/shared.py | 69 ++ 10 files changed, 1193 insertions(+), 6 deletions(-) create mode 100644 editor/resources/index.html create mode 100644 editor/resources/template.ejs create mode 100644 editor/server/__init__.py create mode 100644 editor/server/editor.py create mode 100644 editor/server/examples.py create mode 100644 editor/server/models.py create mode 100644 editor/server/shared.py diff --git a/.gitignore b/.gitignore index 74f6beb..f2751ab 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,10 @@ build/ __pycache__/ dist/ +node_modules/ + .venv/ venv/ ttt/ + +instance/ diff --git a/cloudflare_error_page/__init__.py b/cloudflare_error_page/__init__.py index 7b6a96e..311bb14 100644 --- a/cloudflare_error_page/__init__.py +++ b/cloudflare_error_page/__init__.py @@ -1,3 +1,4 @@ +import html import os import secrets from datetime import datetime, timezone @@ -18,7 +19,7 @@ def get_resources_folder() -> str: return os.path.join(os.path.dirname(os.path.abspath(__file__)), 'resources') -def render(params: dict, use_cdn: bool=True) -> str: +def render(params: dict, allow_html: bool=True, use_cdn: bool=True, show_creator: bool=False) -> str: """ Render a customized Cloudflare error page. """ @@ -28,6 +29,9 @@ def render(params: dict, use_cdn: bool=True) -> str: params['time'] = utc_now.strftime("%Y-%m-%d %H:%M:%S UTC") if not params.get('ray_id'): params['ray_id'] = secrets.token_hex(8) + if not allow_html: + params['what_happened'] = html.escape(params.get('what_happened', '')) + params['what_can_i_do'] = html.escape(params.get('what_can_i_do', '')) template = env.get_template("error.html") - return template.render(params=params, resources_use_cdn=use_cdn) + return template.render(params=params, resources_use_cdn=use_cdn, show_creator=show_creator) diff --git a/cloudflare_error_page/templates/error.html b/cloudflare_error_page/templates/error.html index e27f070..38447d0 100644 --- a/cloudflare_error_page/templates/error.html +++ b/cloudflare_error_page/templates/error.html @@ -50,7 +50,7 @@ {% set default_name = 'Host' -%} {% endif %} {% set item = params.get(item_id + '_status', {}) -%} - {% set status = item.get('status', 'ok') -%} + {% set status = item.status or 'ok' -%} {% if item.status_text_color %} {% set text_color = item.status_text_color -%} {% elif status == 'ok' %} @@ -58,7 +58,7 @@ {% elif status == 'error' %} {% set text_color = '#bd2426' -%} {# text-red-error #} {% endif %} - {% set status_text = item.get('status_text', 'Working' if status == 'ok' else 'Not Working') -%} + {% set status_text = item.status_text or ('Working' if status == 'ok' else 'Not Working') -%}
@@ -96,8 +96,13 @@ - {% set perf_sec_by = params.get('perf_sec_by', {}) %} - Performance & security by {{perf_sec_by.get('text', 'Cloudflare')}} + {% set perf_sec_by = params.perf_sec_by or {} %} + Performance & security by {{perf_sec_by.text or 'Cloudflare'}} + + {% if show_creator %} + + Created by CF Error Page Editor + {% endif %}

diff --git a/editor/resources/index.html b/editor/resources/index.html new file mode 100644 index 0000000..759c864 --- /dev/null +++ b/editor/resources/index.html @@ -0,0 +1,783 @@ + + + + + + + + + Cloudflare Error Page Editor + + + + + + + + + + +
+
+ + +
+
Cloudflare Error Page Editor
+
+
+ + +
+ +
+ +
+ + +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+
+ + +
+
Visit ... for more information
+ +
+
+ +
+ + +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+
+
+ + +
+
Status
+ + +
+
+ Browser +
+ + +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+
+ + +
+
+ Cloudflare +
+ + +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+
+ + +
+
+ Host +
+ + +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+
+
+ + +
+ +
+ + + + +
+ +
+ +
+ +
Performance & security by ...
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ + +
+ + + +
+ + +
+
+ + +
+
+ +
>> Star this project on + Github ⭐ +
+ +
You can also embed the error page into your own website. See + Quickstart in the + homepage. +
+ +
+
+ + +
+
+
Preview
+
Preview updates automatically on change
+
+ + +
+ +
+
+ + + + + + + \ No newline at end of file diff --git a/editor/resources/template.ejs b/editor/resources/template.ejs new file mode 100644 index 0000000..61f4b8b --- /dev/null +++ b/editor/resources/template.ejs @@ -0,0 +1,118 @@ +<% +let resources_cdn = resources_use_cdn ? 'https://cloudflare.com' : ''; +%> + + + + + + +<% +let error_code = params.error_code || 500; +let title = params.title || 'Internal server error'; +let html_title_output = params.html_title || (error_code + ': ' + title); +%> +<%= html_title_output %> + + + + + + + + +
+
+
+

+ <%= title %> + Error code <%= error_code %> +

+ <% let more_info = params.more_information || {}; %> + <% if (!more_info.hidden) { %> +
+ Visit <%= more_info.text || 'cloudflare.com' %> for more information. +
+ <% } %> +
<%= params.time %>
+
+
+
+
+ <% for (let item_id of ['browser', 'cloudflare', 'host']) { %> + <% + let icon, default_location, default_name, text_color, status_text; + + if (item_id === 'browser') { + icon = 'browser'; + default_location = 'You'; + default_name = 'Browser'; + } else if (item_id === 'cloudflare') { + icon = 'cloud'; + default_location = 'San Francisco'; + default_name = 'Cloudflare'; + } else { + icon = 'server'; + default_location = 'example.com'; + default_name = 'Host'; + } + + let item = params[item_id + '_status'] || {}; + let status = item.status || 'ok'; + + if (item.status_text_color) { + text_color = item.status_text_color; + } else if (status === 'ok') { + text_color = '#9bca3e'; // text-green-success + } else if (status === 'error') { + text_color = '#bd2426'; // text-red-error + } + + status_text = item.status_text || (status === 'ok' ? 'Working' : 'Not Working'); + %> +
+
+ + +
+ <%= item.location || default_location %> +

<%= item.name || default_name %>

+ <%= status_text %> +
+ <% } %> +
+
+
+ +
+
+
+

What happened?

+ <%= (params.what_happened || 'There is an internal server error on Cloudflare\'s network.') %> +
+
+

What can I do?

+ <%= (params.what_can_i_do || 'Please try again in a few minutes.') %> +
+
+
+ + +
+
+ + + \ No newline at end of file diff --git a/editor/server/__init__.py b/editor/server/__init__.py new file mode 100644 index 0000000..3937a8b --- /dev/null +++ b/editor/server/__init__.py @@ -0,0 +1,106 @@ +# SPDX-License-Identifier: MIT + +import os +import secrets +import string +import sys + +from flask import Flask, request +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy import event +from sqlalchemy.orm import DeclarativeBase + +root_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '../../') +sys.path.append(root_dir) +from cloudflare_error_page import render as render_cf_error_page + +class Base(DeclarativeBase): + pass + +db: SQLAlchemy = SQLAlchemy(model_class=Base, session_options={ + # 'autobegin': False, + # 'expire_on_commit': False, +}) + +limiter: Limiter = Limiter( + key_func=get_remote_address, # Uses client's IP address by default + default_limits=["200 per day", "50 per hour"] # Global default limits +) + +def generate_secret(length=32) -> str: + characters = string.ascii_letters + string.digits # A-Z, a-z, 0-9 + return ''.join(secrets.choice(characters) for _ in range(length)) + + +def create_app(test_config=None) -> Flask: + instance_path = os.getenv('INSTANCE_PATH', None) + if instance_path is not None: + os.makedirs(instance_path, exist_ok=True) + app = Flask(__name__, + instance_path=instance_path, + instance_relative_config=True + ) + app.json.ensure_ascii = False + app.json.mimetype = "application/json; charset=utf-8" + secret_key = os.getenv('SECRET_KEY', '') + if secret_key: + app.secret_key = secret_key + else: + print('Using generated secret') + app.secret_key = generate_secret() + app.config["SQLALCHEMY_DATABASE_URI"] = os.getenv('DATABASE_URI', 'sqlite:///example.db') + url_prefix = os.getenv('URL_PREFIX', '') + + + from . import models + from . import examples + from . import editor + from . import shared + + if app.config["SQLALCHEMY_DATABASE_URI"].startswith('sqlite'): + app.config["SQLALCHEMY_ENGINE_OPTIONS"] = { + 'isolation_level': 'SERIALIZABLE', + # "execution_options": {"autobegin": False} + } + db.init_app(app) + limiter.init_app(app) + + + with app.app_context(): + db.create_all() + # if db.engine.dialect.name == 'sqlite': + # @event.listens_for(db.engine, "connect") + # def enable_foreign_keys(dbapi_connection, connection_record): + # cursor = dbapi_connection.cursor() + # cursor.execute("PRAGMA foreign_keys=ON;") + # cursor.close() + + @app.route('/health') + def health(): + return '', 204 + + app.register_blueprint(editor.bp, url_prefix=f'{url_prefix}/editor') + app.register_blueprint(examples.bp, url_prefix=f'{url_prefix}/examples') + app.register_blueprint(shared.bp, url_prefix=f'{url_prefix}/s') + + return app + + +def get_common_cf_template_params(): + # Get real Ray ID from Cloudflare header + ray_id = request.headers.get('Cf-Ray') + if ray_id: + ray_id = ray_id[:16] + # Get real client ip from Cloudflare header or request.remote_addr + client_ip = request.headers.get('X-Forwarded-For') + if not client_ip: + client_ip = request.remote_addr + return { + 'ray_id': ray_id, + 'client_ip': client_ip, + } + + +__all__ = ['create_app', 'db', 'get_common_cf_template_params', 'render_cf_error_page'] diff --git a/editor/server/editor.py b/editor/server/editor.py new file mode 100644 index 0000000..788d9b1 --- /dev/null +++ b/editor/server/editor.py @@ -0,0 +1,21 @@ +# SPDX-License-Identifier: MIT + +import os + +from flask import ( + Blueprint, + send_from_directory, +) + +from . import get_common_cf_template_params, render_cf_error_page + +root_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '../../') +res_folder = os.path.join(root_dir, 'editor/resources') + +bp = Blueprint('editor', __name__, url_prefix='/') + + +@bp.route('/', defaults={'path': 'index.html'}) +@bp.route('/') +def index(path: str): + return send_from_directory(res_folder, path) diff --git a/editor/server/examples.py b/editor/server/examples.py new file mode 100644 index 0000000..5faec38 --- /dev/null +++ b/editor/server/examples.py @@ -0,0 +1,58 @@ +# SPDX-License-Identifier: MIT + +import os +import re + +from flask import ( + Blueprint, + json, + abort, + redirect, +) + +from . import get_common_cf_template_params, render_cf_error_page + +root_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '../../') +examples_dir = os.path.join(root_dir, 'examples') + +bp = Blueprint('examples', __name__, url_prefix='/') + + +param_cache: dict[str, dict] = {} + +def get_page_params(name: str) -> dict: + name = re.sub(r'[^\w]', '', name) + params = param_cache.get(name) + if params is not None: + return params + try: + with open(os.path.join(examples_dir, f'{name}.json')) as f: + params = json.load(f) + param_cache[name] = params + return params + except Exception as _: + return None + + +@bp.route('/', defaults={'name': 'default'}) +@bp.route('/') +def index(name: str): + name = os.path.basename(name) # keep only the base name + lower_name = name.lower() + print(lower_name, name) + if name != lower_name: + return redirect(lower_name) + else: + name = lower_name + + params = get_page_params(name) + if params is None: + abort(404) + + params = { + **params, + **get_common_cf_template_params(), + } + + # Render the error page + return render_cf_error_page(params, use_cdn=True), 500 diff --git a/editor/server/models.py b/editor/server/models.py new file mode 100644 index 0000000..c321bcb --- /dev/null +++ b/editor/server/models.py @@ -0,0 +1,19 @@ +# SPDX-License-Identifier: MIT + +from sqlalchemy import ( + Column, + DateTime, + Integer, + JSON, + String, + func, +) + +from . import db + + +class Item(db.Model): + id = Column(Integer, primary_key=True, autoincrement=True, nullable=False) + name = Column(String(255), unique=True, nullable=False, index=True) + params = Column(JSON, nullable=False) + time_created = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/editor/server/shared.py b/editor/server/shared.py new file mode 100644 index 0000000..29495e5 --- /dev/null +++ b/editor/server/shared.py @@ -0,0 +1,69 @@ +# SPDX-License-Identifier: MIT + +import random +import string + +from flask import ( + Blueprint, + request, + abort, + jsonify, + url_for, +) + +from . import get_common_cf_template_params, render_cf_error_page +from . import db +from . import limiter +from . import models + +# root_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '../../') +# examples_dir = os.path.join(root_dir, 'examples') + +bp = Blueprint('shared', __name__, url_prefix='/') + + +rand_charset = string.ascii_letters + string.digits + +def get_rand_name(digits=8): + return ''.join(random.choice(rand_charset) for _ in range(digits)) + + +@bp.post('/create') +@limiter.limit("20 per minute") +@limiter.limit("500 per hour") +def create(): + if len(request.data) > 4096: + abort(413) + params = request.json['parameters'] # throws KeyError + # TODO: strip unused params + try: + item = models.Item() + item.name = get_rand_name() + item.params = params + db.session.add(item) + db.session.commit() + except: + db.session.rollback() + return jsonify({ + 'status': 'failed', + }) + return jsonify({ + 'status': 'ok', + 'name': item.name, + 'url': request.host_url[:-1] + url_for('shared.get', name=item.name), + # TODO: better way to handle this + }) + + +@bp.get('/') +def get(name: str): + item = db.session.query(models.Item).filter_by(name=name).first() + if not item: + return abort(404) + params = item.params + params = { + **params, + **get_common_cf_template_params(), + } + # TODO: cache + return render_cf_error_page(params=params, allow_html=False, use_cdn=True, show_creator=True), 200