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', {}) %}
-
+ {% set perf_sec_by = params.perf_sec_by or {} %}
+
+
+ {% if show_creator %}
+
+
+ {% 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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) { %>
+
+ <% } %>
+ <%= 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