Signed-off-by: Arija A. <ari@ari.lt>
This commit is contained in:
2025-10-23 20:36:41 +03:00
parent f6a80c9c4f
commit caf4ac7c06
107 changed files with 3327 additions and 2147 deletions

26
src/app.py Normal file
View File

@@ -0,0 +1,26 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Flask app example"""
from warnings import filterwarnings as filter_warnings
from flask import Flask
from flask_app import create_app
app: Flask = create_app(__name__)
def main() -> int:
"""entry/main function"""
app.run("127.0.0.1", 8080, True)
return 0
if __name__ == "__main__":
assert main.__annotations__.get("return") is int, "main() should return an integer"
filter_warnings("error", category=Warning)
raise SystemExit(main())

376
src/flask_app/__init__.py Normal file
View File

@@ -0,0 +1,376 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Create flask application"""
import hashlib
import os
import re
import secrets
import sys
import time
import typing as t
from datetime import datetime, timedelta, timezone
from functools import lru_cache
from threading import Thread
import flask
import portalocker # type: ignore
from flask_wtf.csrf import CSRFProtect # type: ignore
from werkzeug.exceptions import HTTPException
from werkzeug.middleware.proxy_fix import ProxyFix
from .const import KEY_FILENAME, KEY_SIZE, POW_DIFFICULTY, SOURCE
from .models import * # To preload all models
__all__: t.Tuple[str] = ("create_app",)
INDENTATION: t.Final[re.Pattern[str]] = re.compile(r"(?m)^[ \t]+|^\s*\n")
CSS_REPLACEMENTS: t.Final[t.Dict[str, str]] = {
": ": ":",
", ": ",",
" > ": ">",
" {": "{",
" / ": "/",
" + ": "+",
}
CSS_REPLACEMENTS_RE: t.Final[re.Pattern[str]] = re.compile(
"|".join(map(re.escape, CSS_REPLACEMENTS.keys()))
)
JS_WS_BLOCK: t.Final[re.Pattern[str]] = re.compile(r"@ws.*?@endws", re.DOTALL)
@lru_cache
def unindent_css(css: str) -> str:
"""Unindent CSS"""
return CSS_REPLACEMENTS_RE.sub(
lambda m: CSS_REPLACEMENTS[m.group(0)],
INDENTATION.sub("", css),
)
@lru_cache
def unindent_js(js: str) -> str:
"""Unindent JavaScript"""
parts: t.List[str] = []
last_end: int = 0
for match in JS_WS_BLOCK.finditer(js):
before: str = js[last_end : match.start()]
before_unindented: str = INDENTATION.sub("", before)
parts.append(before_unindented)
parts.append(match.group(0) + "\n")
last_end = match.end()
after: str = js[last_end:]
after_unindented: str = INDENTATION.sub("", after)
parts.append(after_unindented)
return "".join(parts)
def assign_http(app: flask.Flask) -> flask.Flask:
"""Assign HTTP robots stuff"""
# robots
@app.route("/robots.txt", methods=["GET", "POST"])
def __robots__() -> flask.Response:
"""Robots.txt control file"""
robots: str = (
f"""User-agent: *
Disallow: {flask.url_for("static", filename="css")}
Disallow: {flask.url_for("static", filename="js")}
Sitemap: {app.config['PREFERRED_URL_SCHEME']}://{flask.request.host}/sitemap.xml"""
)
response: flask.Response = flask.Response(robots, mimetype="text/plain")
response.headers["Content-Security-Policy"] = "img-src 'self';"
return response
# sitemap
rule: flask.Rule # type: ignore
sitemap: str = (
'<?xml version="1.0" encoding="UTF-8"?>\
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
)
def surl(loc: str) -> str:
"""sitemap url"""
u: str = "<url>"
u += f'<loc>{app.config["PREFERRED_URL_SCHEME"]}://%s{loc}</loc>'
u += "<priority>1.0</priority>"
return u + "</url>"
for rule in app.url_map.iter_rules(): # type: ignore
if rule.alias or not rule.methods or "GET" not in rule.methods: # type: ignore
continue
url: str = rule.rule # type: ignore
if ">" not in url:
sitemap += surl(url) # type: ignore
sitemap += "</urlset>"
@app.route("/sitemap.xml", methods=["GET", "POST"])
def __sitemap__() -> flask.Response:
"""Sitemap (website mapping)"""
response: flask.Response = flask.Response(
sitemap.replace("%s", flask.request.host), mimetype="application/xml"
)
response.headers["Content-Security-Policy"] = "img-src 'self';"
return response
return app
def create_app(name: str) -> flask.Flask:
"""Create flask application"""
for var in (
"DATABASE",
"LICENSE",
"NAME",
"EMAIL",
"MEMCACHED",
"SOURCE",
"OG_LOCALE",
):
if var not in os.environ or len(os.environ[var]) == 0:
print(
f"Error: Required environment variable {var} is unset", file=sys.stderr
)
sys.exit(1)
app: flask.Flask = flask.Flask(name)
if not app.debug:
app.wsgi_app = ProxyFix( # type: ignore
app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 # type: ignore
)
app.config["PREFERRED_URL_SCHEME"] = "http" if app.debug else "https"
# Workers support
if os.path.exists(KEY_FILENAME) and (
time.time() > os.path.getmtime(KEY_FILENAME) + 60
):
try:
with open(KEY_FILENAME, "rb") as fp:
portalocker.lock(fp, portalocker.LOCK_EX)
os.remove(KEY_FILENAME)
portalocker.unlock(fp)
except Exception:
pass
if not os.path.exists(KEY_FILENAME):
old_umask: int = os.umask(0o177)
try:
with open(KEY_FILENAME, "bx") as fc:
portalocker.lock(fc, portalocker.LOCK_EX)
fc.write(os.urandom(KEY_SIZE))
fc.flush()
portalocker.unlock(fc)
except FileExistsError:
pass
else:
def _remove_key():
time.sleep(60)
with open(KEY_FILENAME, "rb") as fp:
portalocker.lock(fp, portalocker.LOCK_EX)
if os.path.exists(KEY_FILENAME):
os.remove(KEY_FILENAME)
portalocker.unlock(fp)
Thread(target=_remove_key, daemon=True).start()
finally:
os.umask(old_umask)
with open(KEY_FILENAME, "rb") as kp:
portalocker.lock(kp, portalocker.LOCK_SH)
app.config["SECRET_KEY"] = kp.read(KEY_SIZE)
portalocker.unlock(kp)
# General config
app.config["SESSION_COOKIE_NAME"] = "__Host-session"
app.config["SESSION_COOKIE_SAMESITE"] = "strict"
app.config["SESSION_COOKIE_SECURE"] = True
app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["SQLALCHEMY_DATABASE_URI"] = os.environ["DATABASE"]
app.config["SQLALCHEMY_ENGINE_OPTIONS"] = {"pool_pre_ping": True}
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
app.config["USE_SESSION_FOR_NEXT"] = True
# Views
from .views import register_blueprints
register_blueprints(app)
# Database
from .db import db, migrate
db.init_app(app)
migrate.init_app(app, db)
# Rate limiting
from .limiter import limiter
limiter.init_app(app)
limiter.enabled = not app.debug
# CSP + CORP + rate limit
@app.before_request
@limiter.limit("")
def _() -> None:
"""Rate limit all requests"""
flask.g.csp_nonce = secrets.token_urlsafe(18)
@app.after_request
def _(response: flask.Response) -> flask.Response:
"""CSP, CORP, cache, security, and privacy"""
is_static: bool = flask.request.path.startswith(
flask.url_for("static", filename="")
)
csp_header: str = (
response.headers.get("Content-Security-Policy", "").strip().rstrip(";")
)
if is_static and "img-src" not in csp_header:
csp_header = f"{csp_header}; img-src 'self'"
if "Cross-Origin-Embedder-Policy" not in response.headers:
response.headers["Cross-Origin-Embedder-Policy"] = "require-corp"
csp_header = f"{csp_header}; default-src 'none'; base-uri 'none'"
if "form-action" not in csp_header:
csp_header = f"{csp_header}; form-action 'none'"
if not app.debug:
csp_header = f"{csp_header}; upgrade-insecure-requests"
response.headers["Content-Security-Policy"] = (
f"{csp_header.strip(';').strip()};"
)
if "Cross-Origin-Resource-Policy" not in response.headers:
response.headers["Cross-Origin-Resource-Policy"] = "same-origin"
if "X-Frame-Options" not in response.headers:
response.headers["X-Frame-Options"] = "DENY"
if "Referrer-Policy" not in response.headers:
response.headers["Referrer-Policy"] = "no-referrer"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-XSS-Protection"] = "0"
response.headers["Permissions-Policy"] = (
"accelerometer=(), autoplay=(), camera=(), cross-origin-isolated=(), display-capture=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), web-share=(), xr-spatial-tracking=(), clipboard-read=(), clipboard-write=(), gamepad=(), hid=(), idle-detection=(), interest-cohort=(), serial=(), unload=()"
)
response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
if is_static:
content_type: str = response.headers.get("Content-Type", "")
if "font/" in content_type:
ext: int = 60 * 60 * 24 * 30 * 6 # 6 months
else:
ext = 60 * 60 * 24 * 7 # 7 days
response.headers["Cache-Control"] = f"public, max-age={ext}"
expire_time: datetime = datetime.now(timezone.utc) + timedelta(seconds=ext)
response.headers["Expires"] = expire_time.strftime(
"%a, %d %b %Y %H:%M:%S GMT"
)
def process_resource_response(processor: t.Callable[[str], str]) -> None:
"""Process resource response"""
response.direct_passthrough = False
content: str = response.get_data(as_text=True)
modified: str = processor(content)
response.set_data(modified)
etag: str = hashlib.sha1(modified.encode("utf-8")).hexdigest()
response.set_etag(etag, weak=False)
response.make_conditional(flask.request)
if "text/css" in content_type:
process_resource_response(unindent_css)
elif "text/javascript" in content_type:
process_resource_response(unindent_js)
return response
# CSRF
CSRFProtect(app)
# Error
@app.errorhandler(HTTPException)
def _(e: HTTPException) -> flask.Response:
"""handle http errors"""
response: flask.Response = flask.make_response(
flask.render_template(
"error.j2",
code=e.code,
summary=e.name,
description=(e.description or f"HTTP error code {e.code}"),
),
)
response.headers["Content-Security-Policy"] = (
f"style-src 'self'; img-src 'self'; script-src 'nonce-{flask.g.csp_nonce}';"
)
response.status_code = e.code or 200
return response
# Template context
license: str = os.environ["LICENSE"]
author_name: str = os.environ["NAME"]
email: str = os.environ["EMAIL"]
og_locale: str = os.environ["OG_LOCALE"]
@app.context_processor # type: ignore
def _() -> t.Any:
"""Context processor"""
now: datetime = datetime.now(timezone.utc)
return {
"current_year": now.year,
"pow_difficulty": POW_DIFFICULTY,
"license": license,
"name": author_name,
"email": email,
"source_code": SOURCE,
"csp_nonce": flask.g.csp_nonce,
"locale": og_locale,
}
# Robots stuff
assign_http(app)
return app

18
src/flask_app/const.py Normal file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Constants"""
import os
from typing import Final
TEXT_SIZE_MAX: Final[int] = 2048
POW_DIFFICULTY: Final[int] = 5
POW_DIFFICULTY_STR: Final[str] = "0" * POW_DIFFICULTY
POW_EXPIRES: Final[int] = 60 * 16 # 16 minutes
POW_NONCE_SIZE: Final[int] = 12
KEY_FILENAME: Final[str] = ".key"
KEY_SIZE: Final[int] = 128
SOURCE: Final[str] = os.environ.get("SOURCE", "http://127.0.0.1/")

16
src/flask_app/db.py Normal file
View File

@@ -0,0 +1,16 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Flask Database"""
from typing import Tuple
from flask_migrate import Migrate # type: ignore
from flask_sqlalchemy import SQLAlchemy # type: ignore
__all__: Tuple[str, str] = (
"db",
"migrate",
)
db: SQLAlchemy = SQLAlchemy()
migrate: Migrate = Migrate()

View File

@@ -0,0 +1,12 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Forms"""
import typing as t
from . import prow, text
__all__: t.Tuple[str, ...] = (
"text",
"prow",
)

View File

@@ -0,0 +1,64 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Proof-of-Work"""
import hashlib
import secrets
import time
import typing as t
import flask
from flask_app import const
__all__: t.Tuple[str, str] = (
"proof_of_work_protect_session",
"proof_of_work_verify_session",
)
def proof_of_work_protect_session(name: str) -> str:
"""Add a new proof-of-work session, returning a nonce"""
name = name.strip()
if not name:
raise NameError("PoW solution name cannot be empty.")
nonce: str = secrets.token_hex(const.POW_NONCE_SIZE // 2)
flask.session[f"_pow_{name}_nonce"] = nonce
flask.session[f"_pow_{name}_expires"] = time.time() + const.POW_EXPIRES
return nonce
def proof_of_work_verify_session(name: str, solution: str) -> bool:
"""Verify the proof-of-work solution using HMAC"""
if not (solution.isascii() and solution.isdigit()):
return False
nonce_key: str = f"_pow_{name}_nonce"
expires_key: str = f"_pow_{name}_expires"
if nonce_key not in flask.session or expires_key not in flask.session:
return False
nonce: str = flask.session[nonce_key]
expires: float = flask.session[expires_key]
flask.session.pop(nonce_key, None)
flask.session.pop(expires_key, None)
if len(nonce) != const.POW_NONCE_SIZE:
return False
if time.time() > expires:
return False
return (
hashlib.sha256((nonce + solution).encode("ascii"))
.hexdigest()
.startswith(const.POW_DIFFICULTY_STR)
)

View File

@@ -0,0 +1,36 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Text form"""
import typing as t
from flask_wtf import FlaskForm # type: ignore
from wtforms import HiddenField, SubmitField, TextAreaField # type: ignore
from wtforms.validators import DataRequired, Length # type: ignore
from flask_app.const import TEXT_SIZE_MAX
__all__: t.Tuple[str, ...] = ("TextForm",)
class TextForm(FlaskForm):
"""Example text form"""
text = TextAreaField(
"Enter some text here",
validators=(
DataRequired(message="You must enter text"),
Length(
min=1,
max=TEXT_SIZE_MAX,
message=f"Invalid length (min=1, max={TEXT_SIZE_MAX}",
),
),
)
pow_solution = HiddenField(
"Proof of Work Solution",
validators=[DataRequired(message="Proof of work is required to submit.")],
)
form_submit = SubmitField("Submit text")

15
src/flask_app/limiter.py Normal file
View File

@@ -0,0 +1,15 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Rate limiting"""
import os
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
limiter: Limiter = Limiter(
get_remote_address,
default_limits=["125 per minute", "25 per second"],
storage_uri=f"memcached://{os.environ['MEMCACHED']}",
key_prefix="flask_app_",
)

View File

@@ -0,0 +1,9 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Models"""
from typing import Tuple
from .text import TextModel
__all__: Tuple[str, ...] = ("TextModel",)

View File

@@ -0,0 +1,37 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Text model"""
import datetime
import typing as t
from sqlalchemy import DateTime, Unicode
from flask_app.const import TEXT_SIZE_MAX
from flask_app.db import db
__all__: t.Tuple[str] = ("TextModel",)
class TextModel(db.Model):
"""Some text model"""
id = db.Column(
db.Integer,
unique=True,
primary_key=True,
autoincrement=True,
)
text = db.Column(
Unicode(TEXT_SIZE_MAX),
nullable=False,
)
date = db.Column(
DateTime,
default=lambda: datetime.datetime.now(datetime.timezone.utc),
nullable=False,
)
def __init__(self, text: str) -> None:
assert text and len(text) <= TEXT_SIZE_MAX
self.text = text

View File

@@ -0,0 +1,19 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Views"""
from typing import Tuple
from flask import Flask
__all__: Tuple[str] = ("register_blueprints",)
def register_blueprints(app: Flask) -> Flask:
"""Assign all blueprints and their URL prefixes"""
from .home import home
app.register_blueprint(home, url_prefix="/")
return app

261
src/flask_app/views/bp.py Normal file
View File

@@ -0,0 +1,261 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Blueprint"""
import re
from datetime import datetime, timedelta, timezone
from functools import wraps
from typing import Any, Callable, Final, Optional, Tuple, TypeVar, Union
from flask import Blueprint, Response, g, make_response
__all__: Tuple[str] = ("Bp",)
F = TypeVar("F", bound=Callable[..., Any])
DURATION_PATTERN: Final[re.Pattern[str]] = re.compile(
r"(?P<value>\d+)\s*(?P<unit>[dDhHmMsS])"
)
FRAME_ANCESTORS: Final[re.Pattern[str]] = re.compile(r"frame-ancestors\s+([^;]+)")
def parse_duration(duration_str: str) -> int:
"""Parse duration pattern e.g. '1d 2h 30m 15s' and return total seconds."""
duration_str = duration_str.lower().strip()
if duration_str == "0":
return 0
matches: Any = DURATION_PATTERN.findall(duration_str)
total_seconds: int = 0
for value, unit in matches:
value_int: int = int(value)
unit = unit.lower()
if unit == "d":
total_seconds += value_int * 86400
elif unit == "h":
total_seconds += value_int * 3600
elif unit == "m":
total_seconds += value_int * 60
elif unit == "s":
total_seconds += value_int
return total_seconds
class Bp(Blueprint):
"""Blueprint wrapper with utilities"""
def get(self, rule: str, ishtml: bool = False, **kwargs: Any) -> Callable[[F], F]:
"""Wrapper for GET"""
def decorator(f: F) -> F:
self.route(rule, methods=("GET",), **kwargs)(f)
if ishtml:
base_rule: str = rule.rstrip("/")
alias: bool = kwargs.pop("alias", True)
if base_rule:
self.route(
base_rule + "/", methods=("GET",), alias=alias, **kwargs
)(f)
self.route(
base_rule + ".html", methods=("GET",), alias=alias, **kwargs
)(f)
else:
self.route("/index.html", methods=("GET",), alias=alias, **kwargs)(
f
)
return f
return decorator
def post(self, rule: str, **kwargs: Any) -> Callable[[F], F]:
"""Wrapper for POST"""
def decorator(f: F) -> F:
self.route(rule, methods=("POST",), **kwargs)(f)
return f
return decorator
def csp(self, policy: Union[Callable[..., Any], str]) -> Callable[[F], F]:
"""Decorator to set Content-Security-Policy header for this route"""
if callable(policy):
csp_policy: str = getattr(policy, "_csp_policy", "")
else:
csp_policy = policy
for disallowed in ("default-src", "upgrade-insecure-requests", "base-uri"):
if disallowed in csp_policy:
raise ValueError(
f"Disallowed CSP directive {disallowed!r} should not be in custom CSP"
)
def decorator(f: F) -> F:
previous_policy = getattr(f, "_csp_policy", "")
combined_policy = ";".join(
filter(None, [previous_policy.strip(";"), csp_policy.strip(";")])
)
@wraps(f)
def wrapped(*args: Any, **kwargs: Any):
result: Any = f(*args, **kwargs)
response: Response = make_response(result)
existing_csp: str = (
response.headers.get("Content-Security-Policy", "")
.strip()
.rstrip(";")
)
final_policy: str = combined_policy
final_policy = (
final_policy.replace("$nonce", f"'nonce-{g.csp_nonce}'")
.replace("$self", "'self'")
.replace("$none", "'none'")
.replace("$internal", f"'self' 'nonce-{g.csp_nonce}'")
.replace("$wasm", "'wasm-unsafe-eval'")
.strip()
.rstrip(";")
)
full_csp: str = (
";".join(filter(None, [existing_csp, final_policy])) + ";"
)
if "frame-ancestors" in full_csp:
match = FRAME_ANCESTORS.search(full_csp)
if match:
ancestors_value = match.group(1).strip()
if ancestors_value == "'none'":
response.headers["X-Frame-Options"] = "DENY"
elif ancestors_value == "'self'":
response.headers["X-Frame-Options"] = "SAMEORIGIN"
response.headers["Content-Security-Policy"] = full_csp
return response
setattr(wrapped, "_csp_policy", combined_policy)
return wrapped # type: ignore
return decorator
def corp(
self, origin: Union[str, Callable[..., Any]], *methods: str
) -> Callable[[F], F]:
"""Decoreator to set the Cross-Origin-Resource-Policy"""
if callable(origin):
origin_str: str = getattr(origin, "_corp_origin")
methods = getattr(origin, "_corp_methods")
else:
origin_str = origin
if not methods:
raise ValueError("Must specify at least one method.")
def decorator(f: F) -> Any:
@wraps(f)
def wrapped(*args: Any, **kwargs: Any):
result: Any = f(*args, **kwargs)
response: Response = make_response(result)
response.headers["Cross-Origin-Resource-Policy"] = "cross-origin"
response.headers["Access-Control-Allow-Origin"] = origin_str
response.headers["Access-Control-Allow-Methods"] = ", ".join(methods)
return response
setattr(wrapped, "_corp_origin", origin)
setattr(wrapped, "_corp_methods", methods)
return wrapped
return decorator
def cache(
self, duration: Optional[Union[str, Callable[..., Any]]]
) -> Callable[[F], F]:
"""Decoreator to set the Cache Control"""
if callable(duration):
duration_sec: int = getattr(duration, "_duration_sec", 0)
else:
duration_sec = parse_duration(duration) if duration else 0
def decorator(f: F) -> Any:
@wraps(f)
def wrapped(*args: Any, **kwargs: Any):
result: Any = f(*args, **kwargs)
response: Response = make_response(result)
if duration_sec == 0:
response.headers["Cache-Control"] = "no-cache, must-revalidate"
response.headers["Expires"] = "Thu, 1 Jan 1970 00:00:00 GMT"
else:
response.headers["Cache-Control"] = (
f"public, max-age={duration_sec}"
)
expire_time: datetime = datetime.now(timezone.utc) + timedelta(
seconds=duration_sec
)
response.headers["Expires"] = expire_time.strftime(
"%a, %d %b %Y %H:%M:%S GMT"
)
return response
setattr(wrapped, "_duration_sec", duration_sec)
return wrapped
return decorator
def refpol(self, policy: Union[str, Callable[..., Any]]) -> Callable[[F], F]:
"""Decoreator to set the Referrer Policy"""
if callable(policy):
ref_policy: str = getattr(policy, "_ref_policy", "no-referrer")
else:
ref_policy = policy
def decorator(f: F) -> Any:
@wraps(f)
def wrapped(*args: Any, **kwargs: Any):
result: Any = f(*args, **kwargs)
response: Response = make_response(result)
response.headers["Referrer-Policy"] = ref_policy
return response
setattr(wrapped, "_ref_policy", ref_policy)
return wrapped
return decorator
def coep(self, policy: Union[str, Callable[..., Any]]) -> Callable[[F], F]:
"""Decoreator to set the Cross-Origin-Embedder-Policy"""
if callable(policy):
coep_policy: str = getattr(policy, "_coep_policy")
else:
coep_policy = policy
def decorator(f: F) -> Any:
@wraps(f)
def wrapped(*args: Any, **kwargs: Any):
result: Any = f(*args, **kwargs)
response: Response = make_response(result)
response.headers["Cross-Origin-Embedder-Policy"] = coep_policy
return response
setattr(wrapped, "_coep_policy", coep_policy)
return wrapped
return decorator

View File

@@ -0,0 +1,97 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Home routes"""
import typing as t
import flask
from werkzeug.wrappers import Response
import flask_app.forms.prow as form_pow
import flask_app.forms.text as text_form
from flask_app.db import db
from flask_app.models import TextModel
from .bp import Bp
home: Bp = Bp("home", __name__)
__all__: t.Tuple[str] = ("home",)
@home.get("/", ishtml=True)
@home.csp("img-src $self; script-src $internal $wasm; style-src $internal; form-action $self; connect-src $self; manifest-src $self")
def index() -> str:
"""Home page"""
nonce: str = form_pow.proof_of_work_protect_session("home.index/text")
return flask.render_template(
"home/index.j2",
form=text_form.TextForm(),
texts=TextModel.query.all(),
pow_nonce=nonce,
)
@home.post("/")
@home.csp("img-src $self; script-src $internal; style-src $internal")
def text() -> Response:
"""Post some text"""
form: text_form.FlaskForm = text_form.TextForm()
if not form.validate_on_submit(): # type: ignore
flask.flash("Invalid form data/form", "error")
flask.abort(400)
if form.text.data is None:
flask.flash("Invalid form text", "error")
flask.abort(400)
if form.pow_solution.data is None or not form_pow.proof_of_work_verify_session(
"home.index/text",
form.pow_solution.data,
):
flask.flash("Invalid Proof-of-Work solution", "error")
flask.abort(403)
db.session.add(TextModel(text=form.text.data))
db.session.commit()
flask.flash("Your text has been saved")
return flask.redirect(flask.url_for("home.index"))
@home.get("/manifest.json")
@home.csp("manifest-src $self; img-src $self")
@home.cache("30d")
def manifest() -> t.Any:
"""Manifest file"""
return flask.jsonify( # type: ignore
{
"$schema": "https://json.schemastore.org/web-manifest-combined.json",
"short_name": "Example page",
"name": "Example page",
"description": "This is an example description",
"icons": [
{
"src": "/favicon.ico",
"sizes": "256x256",
"type": "image/vnd.microsoft.icon",
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#fbfbfb",
"background_color": "#121212",
}
)
@home.get("/favicon.ico")
@home.csp("img-src $self")
@home.cache("30d")
def favicon() -> Response:
"""Website icon"""
return flask.send_from_directory("static", "favicon.ico")

1
src/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
src/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,27 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
import flask_app
${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,37 @@
"""Initial migration
Revision ID: 57fa0d90dc93
Revises:
Create Date: 2025-07-30 20:21:21.163194
"""
from alembic import op
import sqlalchemy as sa
import flask_app
# revision identifiers, used by Alembic.
revision = '57fa0d90dc93'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('text_model',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('text', sa.Unicode(length=2048), nullable=False),
sa.Column('date', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('text_model')
# ### end Alembic commands ###

68
src/static/css/base.css Normal file
View File

@@ -0,0 +1,68 @@
:root {
color-scheme: dark;
}
*,
*::before,
*::after {
-webkit-box-sizing: border-box;
box-sizing: border-box;
word-wrap: break-word;
scroll-behavior: smooth;
}
code,
code *,
pre,
pre * {
white-space: pre-wrap;
white-space: -moz-pre-wrap;
white-space: -pre-wrap;
white-space: -o-pre-wrap;
}
html {
height: 100%;
min-height: 100%;
}
body {
height: auto;
min-height: 100%;
padding: 2rem;
min-height: 100vh;
}
html,
body {
line-height: 1.5;
}
ol,
ul,
ol *,
ul * {
line-height: 1.8;
}
code {
white-space: pre-wrap !important;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
-webkit-transition: none !important;
-o-transition: none !important;
transition: none !important;
-webkit-animation: none !important;
animation: none !important;
-webkit-animation-play-state: paused !important;
animation-play-state: paused !important;
scroll-behavior: auto !important;
}
}

3
src/static/css/error.css Normal file
View File

@@ -0,0 +1,3 @@
article {
text-align: center;
}

BIN
src/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

353
src/static/js/pow.js Normal file
View File

@@ -0,0 +1,353 @@
/**
* @licstart The following is the entire license notice for the JavaScript
* code in this file.
*
* Copyright (C) 2025 Arija A. <ari@ari.lt>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* @licend The above is the entire license notice for the JavaScript code
* in this file.
*/
"use strict";
const pow_text_encoder = new TextEncoder();
/**
* Minimal pure JavaScript SHA-256 implementation.
*/
function pow_sha256_js(msg_uint8) {
const K = new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1,
0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786,
0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147,
0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a,
0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
]);
function right_rotate(value, amount) {
return (value >>> amount) | (value << (32 - amount));
}
const msg_length = msg_uint8.length;
const bit_length = msg_length * 8;
/* Padding */
const padded_length =
((msg_length + 9 + 63) >> 6) << 6; /* multiple of 64 */
const padded = new Uint8Array(padded_length);
padded.set(msg_uint8);
padded[msg_length] = 0x80;
const dv = new DataView(padded.buffer);
/* Append 64-bit big-endian length */
dv.setUint32(
padded_length - 8,
Math.floor(bit_length / 0x100000000),
false,
);
dv.setUint32(padded_length - 4, bit_length & 0xffffffff, false);
/* Initial hash values */
let h0 = 0x6a09e667;
let h1 = 0xbb67ae85;
let h2 = 0x3c6ef372;
let h3 = 0xa54ff53a;
let h4 = 0x510e527f;
let h5 = 0x9b05688c;
let h6 = 0x1f83d9ab;
let h7 = 0x5be0cd19;
const w = new Uint32Array(64);
for (let i = 0; i < padded_length; i += 64) {
for (let t = 0; t < 16; ++t) {
w[t] = dv.getUint32(i + t * 4, false);
}
for (let t = 16; t < 64; ++t) {
const s0 =
right_rotate(w[t - 15], 7) ^
right_rotate(w[t - 15], 18) ^
(w[t - 15] >>> 3);
const s1 =
right_rotate(w[t - 2], 17) ^
right_rotate(w[t - 2], 19) ^
(w[t - 2] >>> 10);
w[t] = (w[t - 16] + s0 + w[t - 7] + s1) >>> 0;
}
let a = h0;
let b = h1;
let c = h2;
let d = h3;
let e = h4;
let f = h5;
let g = h6;
let h = h7;
for (let t = 0; t < 64; ++t) {
const S1 =
right_rotate(e, 6) ^ right_rotate(e, 11) ^ right_rotate(e, 25);
const ch = (e & f) ^ (~e & g);
const temp1 = (h + S1 + ch + K[t] + w[t]) >>> 0;
const S0 =
right_rotate(a, 2) ^ right_rotate(a, 13) ^ right_rotate(a, 22);
const maj = (a & b) ^ (a & c) ^ (b & c);
const temp2 = (S0 + maj) >>> 0;
h = g;
g = f;
f = e;
e = (d + temp1) >>> 0;
d = c;
c = b;
b = a;
a = (temp1 + temp2) >>> 0;
}
h0 = (h0 + a) >>> 0;
h1 = (h1 + b) >>> 0;
h2 = (h2 + c) >>> 0;
h3 = (h3 + d) >>> 0;
h4 = (h4 + e) >>> 0;
h5 = (h5 + f) >>> 0;
h6 = (h6 + g) >>> 0;
h7 = (h7 + h) >>> 0;
}
const hash = new Uint8Array(32);
const hash_view = new DataView(hash.buffer);
hash_view.setUint32(0, h0, false);
hash_view.setUint32(4, h1, false);
hash_view.setUint32(8, h2, false);
hash_view.setUint32(12, h3, false);
hash_view.setUint32(16, h4, false);
hash_view.setUint32(20, h5, false);
hash_view.setUint32(24, h6, false);
hash_view.setUint32(28, h7, false);
return hash;
}
/**
* Compute SHA-256 hash of input Uint8Array.
* Uses native crypto.subtle.digest if available, otherwise falls back to pure JS implementation.
*/
async function pow_sha256(message_buffer) {
if (
typeof crypto === "object" &&
crypto.subtle &&
typeof crypto.subtle.digest === "function"
) {
return await crypto.subtle.digest("SHA-256", message_buffer);
} else {
return pow_sha256_js(message_buffer).buffer;
}
}
/**
* Encode a string to Uint8Array using TextEncoder.
*/
function pow_str_to_u8(str) {
return pow_text_encoder.encode(str);
}
/**
* Check if the hash buffer meets the difficulty.
* Difficulty is number of leading zero hex characters (nibbles).
* This function checks bytes directly for better performance.
*/
function pow_meets_difficulty_bytes(hash_buffer, difficulty) {
const hash_bytes = new Uint8Array(hash_buffer);
const zero_bytes = Math.floor(difficulty / 2);
const half_nibble = difficulty % 2;
for (let idx = 0; idx < zero_bytes; ++idx) {
if (hash_bytes[idx] !== 0) return false;
}
if (half_nibble) {
if ((hash_bytes[zero_bytes] & 0xf0) !== 0) return false;
}
return true;
}
/**
* JS Proof-of-Work
*/
async function js_proof_of_work(button, difficulty, nonce) {
let tries = 0;
let solution = 0;
const nonce_str = nonce.toString();
const batch_size = 150000;
while (true) {
for (let idx = 0; idx < batch_size; ++idx) {
const message = nonce_str + solution.toString();
const message_buffer = pow_str_to_u8(message);
const hash_buffer = await pow_sha256(message_buffer);
if (pow_meets_difficulty_bytes(hash_buffer, difficulty)) {
button.value = `Computed PoW after ${tries + idx} tries`;
return solution.toString();
}
++solution;
}
tries += batch_size;
button.value = `Computing PoW (${tries} tries)`;
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
/**
* WASM Proof-of-Work
*/
async function wasm_proof_of_work(button, difficulty, nonce) {
let pow_found = false;
let pow_solution = null;
const wasm_path = "/static/js/wasm/pow.wasm";
const imports = {
global: {
pow_update: (tries) => {
button.value = `Computing PoW (${tries} tries)`;
},
pow_finish: (tries, solution) => {
button.value = `Computed PoW after ${tries} tries`;
pow_solution = solution;
pow_found = true;
},
},
};
let response = await fetch(wasm_path);
if (!response.ok) {
console.warn(`Failed to fetch WASM ${wasm_path}`);
return null;
}
let wasm_module;
if (
"instantiateStreaming" in WebAssembly &&
typeof WebAssembly.instantiateStreaming === "function"
) {
wasm_module = await WebAssembly.instantiateStreaming(response, imports);
} else {
const bytes = await response.arrayBuffer();
wasm_module = await WebAssembly.instantiate(bytes, imports);
}
const { pow_init, pow_solve_batch, memory } = wasm_module.instance.exports;
const encoder = new TextEncoder();
const nonce_bytes = encoder.encode(nonce);
const memory_view = new Uint8Array(memory.buffer);
memory_view.set(nonce_bytes, 0);
pow_init(0, nonce_bytes.length, difficulty);
while (!pow_found) {
const result = pow_solve_batch();
if (result === 1) {
// Solution found, pow_finish callback fired
break;
} else if (result === -1) {
console.error("WASM PoW (initialisation) error.");
return null;
}
// Yield to UI between batches
await new Promise((resolve) => setTimeout(resolve, 0));
}
return pow_solution === null ? null : pow_solution.toString();
}
/**
* Perform proof of work by finding a solution such that
* SHA-256(nonce + solution) hash has leading zeros per difficulty.
*/
async function proof_of_work(button, difficulty, nonce) {
button.value = "Computing PoW (0 tries)";
await new Promise((resolve) => setTimeout(resolve, 0));
if ("WebAssembly" in window && typeof WebAssembly === "object") {
const sol = await wasm_proof_of_work(button, difficulty, nonce);
if (sol !== null) {
return sol;
} else {
console.warn(
"WebAssembly present but failed to compute PoW using web assembly. Falling back to JavaScript.",
);
}
}
return await js_proof_of_work(button, difficulty, nonce);
}
/**
* Wrapper to handle form submission and disable button during PoW.
*/
async function proof_of_work_powform(form, button, difficulty, nonce) {
const was_disabled = button.disabled;
button.disabled = true;
button.setAttribute("aria-label", "Proof of Work is computing...");
try {
form.elements["pow_solution"].value = await proof_of_work(
button,
difficulty,
nonce,
);
} finally {
button.disabled = was_disabled;
button.setAttribute("aria-label", "Submitting...");
}
}
/**
* Wrapper to handle form submission and disable button during PoW on page load.
*/
function proof_of_work_powform_onload(form_id, button_id, difficulty, nonce) {
const form = document.getElementById(form_id);
const form_submit = document.getElementById(button_id);
form.addEventListener("submit", async (evt) => {
evt.preventDefault();
form_submit.disabled = true;
await proof_of_work_powform(form, form_submit, difficulty, nonce);
form_submit.value = "Submitting...";
form.submit();
});
}

View File

@@ -0,0 +1,19 @@
---
BasedOnStyle: LLVM
IndentWidth: 4
SortIncludes: false
AlignConsecutiveAssignments: true
AlignConsecutiveBitFields: true
AlignConsecutiveMacros: true
AlignEscapedNewlines: true
AllowShortCaseLabelsOnASingleLine: true
AllowShortEnumsOnASingleLine: true
AllowShortFunctionsOnASingleLine: true
AllowShortLambdasOnASingleLine: true
BinPackParameters: false
IndentCaseBlocks: true
IndentCaseLabels: true
IndentExternBlock: true
IndentGotoLabels: true
---

6
src/static/js/wasm/compile.sh Executable file
View File

@@ -0,0 +1,6 @@
#!/bin/sh
set -xeu
clang --target=wasm32 -s -O3 -flto=full -fno-trapping-math -funroll-loops -ffast-math -fno-math-errno -fomit-frame-pointer -fstrict-aliasing -fvisibility-inlines-hidden -fvisibility=hidden -std=c99 -Werror -Wpedantic -pedantic-errors -pedantic -nostdlib -Wl,--no-entry -Wl,--export=pow_init -Wl,--export=pow_solve_batch -o pow.wasm pow.c
chmod 600 pow.wasm

203
src/static/js/wasm/pow.c Normal file
View File

@@ -0,0 +1,203 @@
#include <stdint.h>
#include <stddef.h>
#define BATCH_SIZE 150000
#define MAX_MSG_LEN 256
#define NONCE_MAX_LEN 128
/* Global state */
static char solution_str[32];
static uint8_t hash_output[32];
static uint8_t message_buffer[MAX_MSG_LEN];
static uint32_t nonce_length = 0;
static uint32_t difficulty = 0;
static uint64_t current_solution = 0;
static uint64_t tries = 0;
static uint32_t zero_bytes = 0;
static uint32_t half_nibble = 0;
__attribute__((__import_module__("global"), __import_name__("pow_update"))) void
pow_update(uint64_t tries);
__attribute__((__import_module__("global"), __import_name__("pow_finish"))) void
pow_finish(uint64_t tries, uint64_t solution);
static const uint32_t K[64] = {
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1,
0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786,
0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147,
0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a,
0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
};
#define ROTR(x, n) (((x) >> (n)) | ((x) << (32 - (n))))
#define CH(x, y, z) (((x) & (y)) ^ (~(x) & (z)))
#define MAJ(x, y, z) (((x) & (y)) ^ ((x) & (z)) ^ ((y) & (z)))
#define EP0(x) (ROTR(x, 2) ^ ROTR(x, 13) ^ ROTR(x, 22))
#define EP1(x) (ROTR(x, 6) ^ ROTR(x, 11) ^ ROTR(x, 25))
#define SIG0(x) (ROTR(x, 7) ^ ROTR(x, 18) ^ ((x) >> 3))
#define SIG1(x) (ROTR(x, 17) ^ ROTR(x, 19) ^ ((x) >> 10))
static inline void g_sha256_hash(uint32_t message_length) {
uint32_t h[8] = {0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19};
const uint64_t bit_length = (uint64_t)message_length * 8;
const uint32_t padded_length = ((message_length + 9 + 63) / 64) * 64;
static uint8_t padded_msg[128];
for (uint32_t idx = 0; idx < message_length; ++idx) {
padded_msg[idx] = message_buffer[idx];
}
padded_msg[message_length] = 0x80;
for (uint32_t idx = message_length + 1; idx < padded_length - 8; ++idx) {
padded_msg[idx] = 0;
}
for (int idx = 0; idx < 8; ++idx) {
padded_msg[padded_length - 1 - idx] =
(uint8_t)(bit_length >> (8 * idx));
}
for (uint32_t chunk = 0; chunk < padded_length; chunk += 64) {
uint32_t w[64];
for (int idx = 0; idx < 16; ++idx) {
w[idx] = ((uint32_t)padded_msg[chunk + idx * 4] << 24) |
((uint32_t)padded_msg[chunk + idx * 4 + 1] << 16) |
((uint32_t)padded_msg[chunk + idx * 4 + 2] << 8) |
((uint32_t)padded_msg[chunk + idx * 4 + 3]);
}
for (int idx = 16; idx < 64; ++idx) {
w[idx] =
SIG1(w[idx - 2]) + w[idx - 7] + SIG0(w[idx - 15]) + w[idx - 16];
}
uint32_t a = h[0], b = h[1], c = h[2], d = h[3];
uint32_t e = h[4], f = h[5], g = h[6], h_val = h[7];
for (int idx = 0; idx < 64; ++idx) {
const uint32_t t1 = h_val + EP1(e) + CH(e, f, g) + K[idx] + w[idx];
const uint32_t t2 = EP0(a) + MAJ(a, b, c);
h_val = g;
g = f;
f = e;
e = d + t1;
d = c;
c = b;
b = a;
a = t1 + t2;
}
h[0] += a;
h[1] += b;
h[2] += c;
h[3] += d;
h[4] += e;
h[5] += f;
h[6] += g;
h[7] += h_val;
}
for (int idx = 0; idx < 8; ++idx) {
hash_output[idx * 4] = (h[idx] >> 24) & 0xFF;
hash_output[idx * 4 + 1] = (h[idx] >> 16) & 0xFF;
hash_output[idx * 4 + 2] = (h[idx] >> 8) & 0xFF;
hash_output[idx * 4 + 3] = h[idx] & 0xFF;
}
}
static inline int uint64_to_str(uint64_t number, char *str) {
if (number == 0) {
str[0] = '0';
str[1] = '\0';
return 1;
}
int length = 0;
uint64_t temp = number;
while (temp > 0) {
++length;
temp /= 10;
}
str[length] = '\0';
for (int idx = length - 1; idx >= 0; --idx) {
str[idx] = '0' + (number % 10);
number /= 10;
}
return length;
}
static inline int g_meets_difficulty(void) {
for (uint32_t idx = 0; idx < zero_bytes; ++idx) {
if (hash_output[idx] != 0) {
return 0;
}
}
if (half_nibble && (hash_output[zero_bytes] & 0xF0) != 0) {
return 0;
}
return 1;
}
__attribute__((export_name("pow_init"))) void
pow_init(uintptr_t nonce_ptr, uint32_t _nonce_length, uint32_t _difficulty) {
const uint8_t *nonce = (const uint8_t *)nonce_ptr;
nonce_length =
(_nonce_length > NONCE_MAX_LEN) ? NONCE_MAX_LEN : _nonce_length;
difficulty = _difficulty;
zero_bytes = _difficulty / 2;
half_nibble = _difficulty % 2;
current_solution = 0;
tries = 0;
for (uint32_t idx = 0; idx < nonce_length; ++idx) {
message_buffer[idx] = nonce[idx];
}
}
__attribute__((export_name("pow_solve_batch"))) int pow_solve_batch(void) {
if (nonce_length == 0) {
return 0;
}
for (uint32_t batch_idx = 0; batch_idx < BATCH_SIZE; batch_idx++) {
const int sol_len = uint64_to_str(current_solution, solution_str);
const int message_length = nonce_length + sol_len;
if (message_length > MAX_MSG_LEN) {
return -1;
}
for (int idx = 0; idx < sol_len; ++idx) {
message_buffer[nonce_length + idx] = (uint8_t)solution_str[idx];
}
g_sha256_hash(message_length);
if (g_meets_difficulty()) {
pow_finish(tries + batch_idx, current_solution);
return 1;
}
++current_solution;
}
tries += BATCH_SIZE;
pow_update(tries);
return 0;
}

93
src/templates/base.j2 Normal file
View File

@@ -0,0 +1,93 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Untitled{% endblock %} - {{ request.host | escape }}</title>
<link rel="icon" href="{{ url_for("home.favicon") }}" sizes="128x128" type="image/vnd.microsoft.icon" />
<meta name="description" content="{% block description %}Description of an untitled page.{% endblock %}" />
<meta
name="keywords"
content="sample app, example, testing, {% block keywords %}untitled{% endblock %}"
/>
<meta
name="robots"
content="follow, index, max-snippet:-1, max-video-preview:-1, max-image-preview:large"
/>
<meta property="og:type" content="{% block type %}website{% endblock %}" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="{% block colour %}#121212{% endblock %}" />
<meta property="og:locale" content="{{ locale | escape }}" />
<meta name="foss:src" content="{{ source_code | escape }}" />
<meta name="author" content="{{ name | escape }}" />
<meta name="license" content="{{ license | escape }}" />
<link rel="manifest" href="{{ url_for("home.manifest") }}" />
<link rel="canonical" href="{{ request.scheme | escape }}://{{ request.host | escape }}{{ request.path | escape }}" />
<link rel="og:url" href="{{ request.scheme | escape }}://{{ request.host | escape }}{{ request.path | escape }}" />
<link rel="sitemap" href="/sitemap.xml" type="application/xml" />
<link rel="stylesheet" href="{{ url_for("static", filename="css/base.css") }}" type="text/css" referrerpolicy="no-referrer" />
<script type="text/javascript" nonce="{{ csp_nonce }}">
<!--//--><![CDATA[//><!--
/**
* @licstart The following is the entire license notice for the JavaScript
* code in this page.
*
* Copyright (C) {{ current_year }} {{ name | escape }} <{{ name | escape }}>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* @licend The above is the entire license notice for the JavaScript code
* in this page.
*/
//--><!]]>
</script>
{% block head %}{% endblock %}
</head>
<body>
{%- block body -%}{%- endblock -%}
<main>
<article>
<header>
{%- block header -%}<h1>Untitled</h1>{%- endblock -%}
</header>
<div id="content">
{%- with messages = get_flashed_messages(with_categories=True) -%}
{% if messages %}
<ul>
{% for category, message in messages %}
<li>[{{ category | escape }}] {{ message | escape }}</li>
{% endfor %}
</ul>
{% endif %}
{%- endwith -%}
{%- block content -%}<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>{%- endblock -%}
</div>
<footer>
<p>&copy; {{ current_year }} {{ name | escape }} &lt;<a href="mailto:{{ email | escape }}">{{ email | escape }}</a>&gt;. Licensed under {{ license | escape }}. Source code available <a href="{{ source_code | escape }}" rel="noopener noreferrer" target="_blank">here</a>.</p>
</footer>
</article>
</main>
</body>
</html>

11
src/templates/error.j2 Normal file
View File

@@ -0,0 +1,11 @@
{% extends "base.j2" %}
{% block title %}{{ code }} / {{ summary | escape }}{% endblock %}
{% block head %}<link rel="stylesheet" href="{{ url_for("static", filename="css/error.css")}}" />{% endblock %}
{% block description %}{{ code }} / {{ summary | escape }}{% endblock %}
{% block header %}<h1>{{ code }} / {{ summary | escape }}</h1>{% endblock %}
{% block content %}<p>{{ description | escape }}</p><p>Go back to <a href="{{ url_for("home.index") }}">the homepage</a> or <a href="#prev-page" id="prev-page" rel="noopener noreferrer">the previous page</a> :)</p><script nonce="{{ csp_nonce }}">document.getElementById("prev-page").addEventListener("click",(e)=>{e.preventDefault();history.back()});</script>{% endblock %}

View File

@@ -0,0 +1,47 @@
{% extends "base.j2" %}
{% block title %}Index{% endblock %}
{% block description %}Hello, World! Welcome to my sample Flask website from ari/flask-example!{% endblock %}
{% block keywords %}example, home{% endblock %}
{%- block head -%}
<script src="{{ url_for("static", filename="js/pow.js") }}"></script>
{%- endblock -%}
{%- block header -%}
<h1>Epic home page!!</h1>
{%- endblock -%}
{%- block content -%}
<p>This is the home page!</p>
<p>I love the home page &lt;3</p>
<p>Meow :3</p>
<h2>Some form:</h2>
<form method="POST" id="form" class="form" action="{{ url_for("home.text") }}">
<div class="form-group">
{{ form.text.label(for="form-text") }}
{{ form.text(id="form-text") }}
</div>
<div class="form-hidden">{{ form.hidden_tag() }}</div>
{{ form.form_submit() }}
</form>
<script nonce="{{ csp_nonce }}" defer>proof_of_work_powform_onload("form", "form_submit", {{ pow_difficulty }}, {{ pow_nonce | tojson }});</script>
<h2>Previous texts</h2>
{% for text in texts %}
<div>
<p>{{ text.text | escape }}</p>
<p>Date: {{ text.date }}</p>
</div>
{% endfor %}
{%- endblock -%}