diff --git a/captcha.key b/captcha.key
new file mode 100644
index 0000000..4461e6e
Binary files /dev/null and b/captcha.key differ
diff --git a/requirements.txt b/requirements.txt
index a0cbb23..85f6972 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1,5 @@
flask
types-flask
flask-sqlalchemy
+flask-ishuman
+crc4
diff --git a/src/aw/__init__.py b/src/aw/__init__.py
index 5dd8209..374ecfa 100644
--- a/src/aw/__init__.py
+++ b/src/aw/__init__.py
@@ -3,6 +3,7 @@
"""ari.lt"""
import datetime
+import hashlib
import os
import sys
from typing import Any
@@ -23,6 +24,8 @@ def create_app(name: str) -> flask.Flask:
app.config["PREFERRED_URL_SCHEME"] = "http" if app.debug else "https"
app.config["DOMAIN"] = "ari.lt"
+ app.config["SECRET_KEY"] = os.urandom(4096)
+
app.config["SESSION_COOKIE_SAMESITE"] = "strict"
app.config["SESSION_COOKIE_SECURE"] = True
app.config["SESSION_COOKIE_HTTPONLY"] = True
@@ -31,12 +34,35 @@ def create_app(name: str) -> flask.Flask:
app.config["SQLALCHEMY_ENGINE_OPTIONS"] = {"pool_pre_ping": True}
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
+ app.config["CAPTCHA_PEPPER_FILE"] = "captcha.key"
+ app.config["CAPTCHA_EXPIRY"] = 60 * 10 # 10 minutes
+ app.config["CAPTCHA_CHARSET"] = "abdefghmnqrtyABDEFGHLMNRTY2345689#@%?!"
+ app.config["CAPTCHA_RANGE"] = (4, 6)
+
app.config["USE_SESSION_FOR_NEXT"] = True
+ from .models import Admin, db
+
+ with app.app_context():
+ db.init_app(app)
+ db.create_all()
+
+ # if db.session.query(Admin).count() < 1:
+ # print("Creating an admin account...")
+
+ # full_name: str = input("Full name: ")
+ # email: str = input("Email: ")
+ # salt: bytes = os.urandom(64)
+ # pwhash: bytes = hashlib.sha3_512(salt + input("Password: ")).digest()
+
from .views import views
app.register_blueprint(views, url_prefix="/")
+ from .c import c
+
+ c.init_app(app)
+
@app.context_processor # type: ignore
def _() -> Any:
"""Context processor"""
diff --git a/src/aw/c.py b/src/aw/c.py
new file mode 100644
index 0000000..a6aa029
--- /dev/null
+++ b/src/aw/c.py
@@ -0,0 +1,7 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""Captcha"""
+
+import flask_ishuman
+
+c: flask_ishuman.IsHuman = flask_ishuman.IsHuman()
diff --git a/src/aw/const.py b/src/aw/const.py
index c07181f..61c90ca 100644
--- a/src/aw/const.py
+++ b/src/aw/const.py
@@ -5,3 +5,10 @@
from typing import Final
HUGEINT_MAX: Final[int] = (10**65) - 1
+
+USERNAME_SIZE: Final[int] = 64
+
+NAME_SIZE: Final[int] = 256
+WEBSITE_SIZE: Final[int] = 256
+EMAIL_CT_SIZE: Final[int] = 256
+COMMENT_SIZE: Final[int] = 1024
diff --git a/src/aw/models.py b/src/aw/models.py
index 6b662d3..7b61033 100644
--- a/src/aw/models.py
+++ b/src/aw/models.py
@@ -2,15 +2,20 @@
# -*- coding: utf-8 -*-
"""DB Models"""
+import datetime
+import string
import typing as t
from decimal import Decimal
+from secrets import SystemRandom
+import crc4
from flask_sqlalchemy import SQLAlchemy
-from sqlalchemy import DECIMAL, Dialect, TypeDecorator
+from sqlalchemy import DECIMAL, DateTime, Dialect, TypeDecorator, Unicode
from . import const
db: SQLAlchemy = SQLAlchemy()
+rand: SystemRandom = SystemRandom()
class HugeUInt(TypeDecorator): # type: ignore
@@ -54,7 +59,55 @@ class Counter(db.Model):
id: int = db.Column(
db.Integer,
- primary_key=True,
unique=True,
+ primary_key=True,
)
count: int = db.Column(HugeUInt())
+
+ def __init__(self, count: int = 0) -> None:
+ assert count >= 0 and count <= const.HUGEINT_MAX, "count out of range"
+ self.count: int = count
+
+
+class Comment(db.Model):
+ """Comment"""
+
+ id: int = db.Column(
+ db.Integer,
+ unique=True,
+ primary_key=True,
+ )
+
+ name: str = db.Column(Unicode(const.NAME_SIZE))
+ website: t.Optional[str] = db.Column(db.String(const.WEBSITE_SIZE), nullable=True)
+
+ email_ct: bytes = db.Column(db.LargeBinary(length=const.EMAIL_CT_SIZE))
+ key: bytes = db.Column(db.LargeBinary(length=32))
+
+ comment: str = db.Column(Unicode(const.COMMENT_SIZE))
+ confirmed: bool = db.Column(db.Boolean, default=False)
+ posted: datetime.datetime = db.Column(DateTime, nullable=False)
+
+ token: str = db.Column(db.String(32))
+
+ def __init__(
+ self, name: str, website: t.Optional[str], email: str, comment: str
+ ) -> None:
+ assert len(name) <= const.NAME_SIZE, "Name too long"
+ assert len(website or "") <= const.WEBSITE_SIZE, "Website too long"
+ assert len(email) <= const.EMAIL_CT_SIZE, "Email too long"
+ assert len(comment) <= const.COMMENT_SIZE, "Comment too long"
+
+ self.name: str = name
+ self.website: t.Optional[str] = website
+
+ self.key: bytes = rand.randbytes(32)
+ self.email_ct: bytes = crc4.rc4(self.email.encode(), self.key) # type: ignore
+
+ self.comment: str = comment
+ self.confirmed: bool = False
+ self.posted: datetime.datetime = datetime.datetime.now(datetime.timezone.utc)
+
+ self.token: str = "".join(
+ rand.choices(string.ascii_letters + string.digits, k=32)
+ )
diff --git a/src/aw/views.py b/src/aw/views.py
index b3ba3cb..215c11d 100644
--- a/src/aw/views.py
+++ b/src/aw/views.py
@@ -8,6 +8,7 @@ import flask
from werkzeug.wrappers import Response
from .routing import Bp
+from .c import c
views: Bp = Bp("views", __name__)
@@ -55,6 +56,14 @@ def license() -> flask.Response:
with open("LICENSE", "r") as fp:
return flask.Response(fp.read(), mimetype="text/plain")
+@views.post("/")
+def comment() -> flask.Response:
+ """publish a comment"""
+ return flask.Response(
+ c.new().rawpng(),
+ mimetype="image/png"
+ )
+
@views.get("/git", defaults={"_": ""})
@views.get("/git/", defaults={"_": ""})
@@ -77,3 +86,33 @@ def favicon() -> Response:
mimetype="image/vnd.microsoft.icon",
)
)
+
+@views.get("/captcha.png")
+def captcha() -> flask.Response:
+ """CAPTCHA"""
+ return flask.Response(
+ c.new().rawpng(),
+ mimetype="image/png"
+ )
+
+
+@views.get("/badge.png")
+def badge() -> Response:
+ """Website badge"""
+ return flask.redirect(
+ flask.url_for(
+ "static",
+ filename="badges/badge.png",
+ )
+ )
+
+
+@views.get("/badge-yellow.png")
+def badge_yellow() -> Response:
+ """Website badge (yellow)"""
+ return flask.redirect(
+ flask.url_for(
+ "static",
+ filename="badges/badge-yellow.png",
+ )
+ )
diff --git a/src/static/badges/badge-yellow.png b/src/static/badges/badge-yellow.png
new file mode 100644
index 0000000..4f965c7
Binary files /dev/null and b/src/static/badges/badge-yellow.png differ
diff --git a/src/static/badges/badge.png b/src/static/badges/badge.png
new file mode 100644
index 0000000..9187534
Binary files /dev/null and b/src/static/badges/badge.png differ
diff --git a/src/static/css/base.css b/src/static/css/base.css
index 73c2eab..14a1d92 100644
--- a/src/static/css/base.css
+++ b/src/static/css/base.css
@@ -48,7 +48,7 @@ body {
margin: auto;
padding: 0;
- max-width: 1400px;
+ max-width: 1600px;
text-rendering: optimizeSpeed;
line-height: 1.5;
@@ -94,7 +94,7 @@ li {
margin: 0.5em 0;
}
-p, table {
+p, table, table * {
text-align: justify;
}
@@ -112,6 +112,10 @@ a:hover {
text-decoration: underline;
}
+.mob {
+ display: none;
+}
+
@media (prefers-reduced-motion: reduce) {
*,
*::before,
@@ -129,3 +133,9 @@ a:hover {
scroll-behavior: auto !important;
}
}
+
+@media only screen and (max-width: 1250px) {
+ .mob {
+ display: initial;
+ }
+}
diff --git a/src/static/css/index.css b/src/static/css/index.css
index acc2f41..83c7e29 100644
--- a/src/static/css/index.css
+++ b/src/static/css/index.css
@@ -4,14 +4,24 @@
font-family: Hack, hack, sans-serif;
}
-.split > * {
- padding: 2em;
+.split {
+ display: grid;
+ grid-template-columns: 3fr 1fr;
+ grid-gap: 0.5em;
+ align-items: stretch;
+ height: 100%;
}
-.split {
- display: grid;
- grid-gap: 0.5em;
- grid-template-columns: 3fr 1fr;
+.split > * {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ padding: 2em;
+ height: 100%;
+}
+
+.esplit {
+ grid-template-columns: 1fr 1fr;
}
.split > :first-child {
@@ -24,25 +34,13 @@
position: absolute;
left: 0;
right: 0;
- top: 50%;
- bottom: 50%;
+ top: 0;
+ bottom: 0;
border-right: 2px solid var(--red);
animation: grow 0.5s forwards;
pointer-events: none;
}
-@keyframes grow {
- from {
- top: 50%;
- bottom: 50%;
- }
-
- to {
- top: 0;
- bottom: 0;
- }
-}
-
canvas#particles {
position: fixed;
top: 0;
@@ -53,28 +51,78 @@ canvas#particles {
pointer-events: none;
}
-@media (prefers-reduced-motion: reduce) {
- *,
- *::before,
- *::after {
- -webkit-animation-duration: 0.01ms !important;
- animation-duration: 0.01ms !important;
+form {
+ background-color: #030303;
+ width: 100%;
+ padding: 1em;
+ margin: 1em;
+ border-bottom: 1px solid var(--red);
+}
- -webkit-animation-iteration-count: 1 !important;
- animation-iteration-count: 1 !important;
+form > button {
+ background-color: #111;
+ border: none;
+ padding: 0.5em;
+ width: 100%;
+ cursor: pointer;
+}
- -webkit-transition-duration: 0.01ms !important;
- -o-transition-duration: 0.01ms !important;
- transition-duration: 0.01ms !important;
+.form-group {
+ display: grid;
+ grid-template-columns: 8em auto;
+ grid-gap: 1em;
+ margin: 0.4em;
+}
- scroll-behavior: auto !important;
+.form-group * {
+ border: none;
+ padding: 0.3em;
+}
+
+.form-group > :last-child {
+ background-color: #111;
+ resize: vertical;
+ min-height: 3em;
+}
+
+.captcha {
+ display: grid;
+ place-items: center;
+ margin: 0.5em;
+}
+
+.captcha > img {
+ display: block;
+ cursor: pointer;
+}
+
+#comments > div {
+ background-color: #111;
+ padding: 0.5em;
+ margin: 0.5em;
+}
+
+#comments > div button {
+ border: none;
+ background-color: #000;
+ cursor: pointer;
+ border-bottom: 1px solid var(--red);
+}
+
+@keyframes grow {
+ from {
+ transform: scaleY(0);
+ }
+ to {
+ transform: scaleY(1);
}
}
-@media only screen and (max-width: 1000px) {
+@media only screen and (max-width: 1250px) {
.split {
grid-template-columns: 1fr;
grid-template-rows: auto auto;
+ align-items: unset;
}
.split > :first-child {
@@ -90,11 +138,6 @@ canvas#particles {
display: none;
}
- .split {
- grid-template-columns: 1fr;
- border-bottom: 2px dashed gray;
- }
-
.split > :last-child {
word-break: break-all;
}
diff --git a/src/static/js/index.js b/src/static/js/index.js
index 0951e53..1576515 100644
--- a/src/static/js/index.js
+++ b/src/static/js/index.js
@@ -33,8 +33,9 @@ document.addEventListener("DOMContentLoaded", function () {
canvas.height = window.innerHeight;
let particles = [];
+ const particle_density = 1.5e-5;
+ let num_particles;
- const num_particles = 512;
const particle_size = 2;
const avoidance_radius = 48;
@@ -87,6 +88,10 @@ document.addEventListener("DOMContentLoaded", function () {
class Particle {
constructor() {
+ this.reset();
+ }
+
+ reset() {
this.x = Math.random() * canvas.width;
this.y = Math.random() * canvas.height;
this.velocity = {
@@ -96,19 +101,21 @@ document.addEventListener("DOMContentLoaded", function () {
}
update(particles) {
- if (this.x < 0 || this.x > canvas.width) {
+ if (this.x <= 0 || this.x >= canvas.width) {
this.velocity.x *= -1;
+ this.x = Math.max(Math.min(this.x, canvas.width), 0);
}
- if (this.y < 0 || this.y > canvas.height) {
+ if (this.y <= 0 || this.y >= canvas.height) {
this.velocity.y *= -1;
+ this.y = Math.max(Math.min(this.y, canvas.height), 0);
}
let dx = mouse.x - this.x;
let dy = mouse.y - this.y;
let distance = Math.sqrt(dx * dx + dy * dy);
- if (distance < avoidance_radius) {
+ if (distance < avoidance_radius && distance > 0) {
this.velocity.x += (dx / distance) * 0.5;
this.velocity.y += (dy / distance) * 0.5;
}
@@ -149,13 +156,17 @@ document.addEventListener("DOMContentLoaded", function () {
draw() {
ctx.beginPath();
ctx.arc(this.x, this.y, particle_size, 0, Math.PI * 2);
- ctx.fillStyle = "rgba(255, 81, 71, 0.2)";
+ ctx.fillStyle = "rgba(255, 81, 71, 0.5)";
ctx.fill();
}
}
function init() {
particles = [];
+ num_particles = Math.floor(
+ particle_density * canvas.width * canvas.height,
+ );
+ num_particles = Math.min(num_particles, 1024);
for (let i = 0; i < num_particles; i++) {
particles.push(new Particle());
}
diff --git a/src/templates/base.j2 b/src/templates/base.j2
index 23f0e69..ed048f3 100644
--- a/src/templates/base.j2
+++ b/src/templates/base.j2
@@ -38,7 +38,7 @@
{% block header %}{% endblock %}
{% block main %}{% endblock %}