From dd1578324159d3f40825f2cf94a241c267045090 Mon Sep 17 00:00:00 2001 From: Ari Archer Date: Thu, 6 Jun 2024 17:23:49 +0300 Subject: [PATCH] final beta Signed-off-by: Ari Archer --- README.md | 5 + ari-lt.env.example | 4 + requirements.txt | 5 + src/aw/__init__.py | 21 +- src/aw/const.py | 18 +- src/aw/email.py | 27 +++ src/aw/models.py | 13 +- src/aw/util.py | 25 +++ src/aw/views.py | 263 +++++++++++++++++++++-- src/static/css/base.css | 2 +- src/static/css/index.css | 52 ++++- src/static/js/{index.js => particles.js} | 2 +- src/static/js/rc4.js | 62 ++++++ src/templates/base.j2 | 14 +- src/templates/index.j2 | 100 ++++++--- 15 files changed, 544 insertions(+), 69 deletions(-) create mode 100644 README.md create mode 100644 src/aw/email.py create mode 100644 src/aw/util.py rename src/static/js/{index.js => particles.js} (99%) create mode 100644 src/static/js/rc4.js diff --git a/README.md b/README.md new file mode 100644 index 0000000..0715a1b --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# New era of ari.lt + +Ari.lt has become quite messy over the years, so this is meant to replace the website. + +It is still a work in progress, but it's better than a mess. diff --git a/ari-lt.env.example b/ari-lt.env.example index 318fb2b..2da3411 100644 --- a/ari-lt.env.example +++ b/ari-lt.env.example @@ -1,2 +1,6 @@ # export DB='mysql+pymysql://user:password@127.0.0.1/arilt?charset=utf8mb4' export DB='sqlite:///arilt.db' +export EMAIL_USER='someone@ari.lt' +export EMAIL_SERVER='mail.ari.lt' +export EMAIL_PASSWORD='...' +export ADMIN_KEY='...' diff --git a/requirements.txt b/requirements.txt index 85f6972..d7a6bfb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,8 @@ types-flask flask-sqlalchemy flask-ishuman crc4 +validators +markdown +jinja2 +MarkupSafe +bleach diff --git a/src/aw/__init__.py b/src/aw/__init__.py index 374ecfa..a4219d4 100644 --- a/src/aw/__init__.py +++ b/src/aw/__init__.py @@ -3,18 +3,20 @@ """ari.lt""" import datetime -import hashlib import os import sys +from base64 import b64encode from typing import Any import flask +from . import util + def create_app(name: str) -> flask.Flask: """create ari.lt app""" - for var in ("DB",): + for var in ("DB", "EMAIL_USER", "EMAIL_SERVER", "EMAIL_PASSWORD"): if var not in os.environ: print(f"Environment variable {var} is unset.", file=sys.stderr) sys.exit(1) @@ -41,19 +43,17 @@ def create_app(name: str) -> flask.Flask: app.config["USE_SESSION_FOR_NEXT"] = True - from .models import Admin, db + from .models import Counter, db with app.app_context(): db.init_app(app) db.create_all() - # if db.session.query(Admin).count() < 1: - # print("Creating an admin account...") + if db.session.query(Counter).count() < 1: + print("Creating a website counter...") + db.session.add(Counter(int(input("Count: ")))) - # full_name: str = input("Full name: ") - # email: str = input("Email: ") - # salt: bytes = os.urandom(64) - # pwhash: bytes = hashlib.sha3_512(salt + input("Password: ")).digest() + db.session.commit() from .views import views @@ -63,6 +63,8 @@ def create_app(name: str) -> flask.Flask: c.init_app(app) + app.jinja_env.filters["markdown"] = util.markdown_to_html + @app.context_processor # type: ignore def _() -> Any: """Context processor""" @@ -75,6 +77,7 @@ def create_app(name: str) -> flask.Flask: "programming_exp": y - 2016, "python_exp": y - 2016, "c_exp": y - 2020, + "b64encode": b64encode, } return app diff --git a/src/aw/const.py b/src/aw/const.py index 61c90ca..d9b4e4d 100644 --- a/src/aw/const.py +++ b/src/aw/const.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- """Constants""" -from typing import Final +from typing import Dict, Final, FrozenSet HUGEINT_MAX: Final[int] = (10**65) - 1 @@ -12,3 +12,19 @@ NAME_SIZE: Final[int] = 256 WEBSITE_SIZE: Final[int] = 256 EMAIL_CT_SIZE: Final[int] = 256 COMMENT_SIZE: Final[int] = 1024 + +ALLOWED_TAGS: Final[FrozenSet[str]] = frozenset( + ( + "em", + "strong", + "a", + "code", + "pre", + "blockquote", + "p", + "ul", + "ol", + "li", + "br", + ) +) diff --git a/src/aw/email.py b/src/aw/email.py new file mode 100644 index 0000000..c4d937e --- /dev/null +++ b/src/aw/email.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""email""" + +import os +import smtplib +from email.mime.text import MIMEText + + +def sendmail(to: str, subject: str, content: str) -> None: + """send mail to an address""" + + msg: MIMEText = MIMEText(content) + + msg["Subject"] = f"[Ari-web] {subject}" + msg["From"] = os.environ["EMAIL_USER"] + msg["To"] = to + + server: smtplib.SMTP = smtplib.SMTP(os.environ["EMAIL_SERVER"], 587) + + server.ehlo() + server.starttls() + + server.login(os.environ["EMAIL_USER"], os.environ["EMAIL_PASSWORD"]) + + server.send_message(msg) + server.quit() diff --git a/src/aw/models.py b/src/aw/models.py index 7b61033..fbaf3aa 100644 --- a/src/aw/models.py +++ b/src/aw/models.py @@ -68,6 +68,17 @@ class Counter(db.Model): assert count >= 0 and count <= const.HUGEINT_MAX, "count out of range" self.count: int = count + def inc(self) -> "Counter": + """increment and return self""" + self.count += 1 + db.session.commit() + return self + + @staticmethod + def first() -> "Counter": + """get counter""" + return db.session.query(Counter).first() # type: ignore + class Comment(db.Model): """Comment""" @@ -102,7 +113,7 @@ class Comment(db.Model): 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.email_ct: bytes = crc4.rc4(email.encode(), self.key) # type: ignore self.comment: str = comment self.confirmed: bool = False diff --git a/src/aw/util.py b/src/aw/util.py new file mode 100644 index 0000000..758020e --- /dev/null +++ b/src/aw/util.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""utilities""" + +import bleach +from markdown import markdown +from markupsafe import Markup + +from . import const + + +def markdown_to_html(text: str) -> Markup: + """Convert Markdown text to safe HTML""" + + return Markup( + bleach.clean( + markdown(text, extensions=("extra", "smarty")), + tags=const.ALLOWED_TAGS, + attributes={ + "*": ["href", "title"], + "a": ["href"], + }, + protocols={"http", "https"}, + ) + ) diff --git a/src/aw/views.py b/src/aw/views.py index 215c11d..bfebd1a 100644 --- a/src/aw/views.py +++ b/src/aw/views.py @@ -2,22 +2,171 @@ # -*- coding: utf-8 -*- """views""" +import datetime +import os import typing as t import flask +import validators from werkzeug.wrappers import Response -from .routing import Bp +from . import email, models from .c import c +from .routing import Bp views: Bp = Bp("views", __name__) +status: t.Dict[str, t.Any] = { + "status": "No status", + "last_updated": datetime.datetime.now(datetime.timezone.utc), +} + + +@views.get("/status") +def get_status() -> t.Any: + """Get status""" + return flask.jsonify( # type: ignore + { + "status": status["status"], + "last_updated": status["last_updated"].timestamp(), + } + ) + + +@views.post("/status") +def set_status() -> Response: + """Set status""" + + if ( + "status" in flask.request.form + and flask.request.headers.get("X-Admin-Key", None) == os.environ["ADMIN_KEY"] + ): + status["status"] = str(flask.request.form["status"]) # type: ignore + status["last_updated"] = datetime.datetime.now(datetime.timezone.utc) + return flask.jsonify( # type: ignore + { + "status": status["status"], + "last_updated": status["last_updated"].timestamp(), + } + ) + else: + flask.abort(401) @views.get("/index.html", alias=True) @views.get("/") def index() -> str: """Home page""" - return flask.render_template("index.j2") + + return flask.render_template( + "index.j2", + visitor=models.Counter.first().inc().count, + comments=models.Comment.query.filter_by(confirmed=True).order_by( + models.Comment.posted.desc() # type: ignore + ), + status=status, + ) + + +@views.get("/confirm///", alias=True) +@views.get("/confirm//") +def confirm(comment_id: int, token: str): + """confirm publishing of a comment""" + + comment: models.Comment = models.Comment.query.filter_by( + id=comment_id, token=token, confirmed=False + ).first_or_404() + + comment.confirmed = True + + models.db.session.commit() + + email.sendmail( + "ari@ari.lt", + f"Comment #{comment.id} on the guestbook", + f"""New comment on the guestbook for you to check out. + + +URL: {flask.url_for("views.index")}#{comment.id} +Name: {comment.name} +Website: {comment.website} +Comment: + +``` +{comment.comment} +```""", + ) + + flask.flash(f"Comment #{comment_id} confirmed.") + + return flask.redirect(flask.url_for("views.index")) + + +@views.post("/") +def comment(): + """publish a comment""" + + for field in "name", "email", "comment", "code": + if field not in flask.request.form: + flask.abort(400) + + if not c.verify(flask.request.form["code"]): # type: ignore + flask.abort(403) + + if not validators.email(flask.request.form["email"]): + flask.abort(400) + + if ( + "website" in flask.request.form + and flask.request.form["website"] + and not validators.url(flask.request.form["website"]) + ): + flask.abort(400) + + try: + comment: models.Comment = models.Comment( + flask.request.form["name"], # type: ignore + flask.request.form.get("website", None), # type: ignore + flask.request.form["email"], # type: ignore + flask.request.form["comment"], # type: ignore + ) + except Exception: + flask.abort(400) + + models.db.session.add(comment) + models.db.session.commit() + + try: + email.sendmail( + flask.request.form["email"], # type: ignore + f"Email confirmation for guestbook comment #{comment.id}", + f"""Hello! + +Someone (or you) have commented on the https://ari.lt/ guestbook. If it was you, please confirm your email address below. Else - you may ignore it. + +The comment is: + +Name: {comment.name} +Website: {comment.website or ""} +Comment: + +``` +{comment.comment} +``` + +Visit the following URL to *confirm* your email: + +{flask.request.url.rstrip("/")}{flask.url_for("views.confirm", comment_id=comment.id, token=comment.token)} + +...Or paste it into your browser.""", + ) + except Exception: + models.db.session.delete(comment) + models.db.session.commit() + flask.abort(400) + + flask.flash("Check your mailbox.") + + return flask.redirect(flask.url_for("views.index")) @views.get("/manifest.json") @@ -56,14 +205,6 @@ 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={"_": ""}) @@ -71,7 +212,7 @@ def comment() -> flask.Response: def git(_: str) -> Response: """Git source code""" return flask.redirect( - f"https://ari.lt/lh/us.ari.lt/{flask.request.full_path[4:]}", + f"/lh/ari.lt/{flask.request.full_path[4:]}", code=302, ) @@ -87,32 +228,118 @@ def favicon() -> Response: ) ) + @views.get("/captcha.png") def captcha() -> flask.Response: """CAPTCHA""" - return flask.Response( - c.new().rawpng(), - mimetype="image/png" - ) + return flask.Response(c.new().rawpng(), mimetype="image/png") @views.get("/badge.png") def badge() -> Response: """Website badge""" - return flask.redirect( + r: Response = flask.redirect( flask.url_for( "static", filename="badges/badge.png", ) ) + r.headers["Access-Control-Allow-Origin"] = "*" + r.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS, HEAD" + + return r + @views.get("/badge-yellow.png") def badge_yellow() -> Response: - """Website badge (yellow)""" - return flask.redirect( + """Website badge""" + r: Response = flask.redirect( flask.url_for( "static", filename="badges/badge-yellow.png", ) ) + + r.headers["Access-Control-Allow-Origin"] = "*" + r.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS, HEAD" + + return r + + +@views.get("/btc") +def btc() -> Response: + """Bitcoin address""" + return flask.redirect( + "https://www.blockchain.com/explorer/addresses/btc/bc1qn3k75kmyvpw9sc58t63hk4ej4pc0d0w52tvj7w" + ) + + +@views.get("/xmr") +def xmr() -> Response: + """Monero address""" + return flask.redirect( + "https://moneroexplorer.org/search?value=451VZy8FPDXCVvKWkq5cby3V24ApLnjaTdwDgKG11uqbUJYjxQWZVKiiefi4HvFd7haeUtGFRBaxgKNTr3vR78pkMzgJaAZ" + ) + + +@views.get("/page/canary", alias=True) +@views.get("/canary") +def canary(): + """Warrant Canary""" + return "Unavailable due to migration reasons." + + +@views.get("/page/casey", alias=True) +@views.get("/casey") +def casey(): + """Open letter to my best friend""" + return "Unavailable due to migration reasons." + + +@views.get("/page/matrix", alias=True) +@views.get("/matrix") +def matrix(): + """Matrix homeserver guidelines and Registration""" + return "Unavailable due to migration reasons." + + +@views.get("/mp") +def mp(): + """Music playlist""" + return flask.redirect( + "https://www.youtube.com/playlist?list=PL7UuKajElTaChff3BkcJE6620lSuSUaDC" + ) + + +@views.get("/dotfiles", defaults={"_": ""}) +@views.get("/dotfiles/", defaults={"_": ""}) +@views.get("/dotfiles/") +def dotfiles(_: str) -> Response: + """Dotfiles""" + return flask.redirect( + f"https://github.com/TruncatedDinoSour/dotfiles-cleaned/{flask.request.full_path[9:]}", + code=302, + ) + + +@views.get("/gh", defaults={"_": ""}) +@views.get("/gh/", defaults={"_": ""}) +@views.get("/gh/") +def gh(_: str) -> Response: + """Main git account""" + return flask.redirect( + f"https://github.com/TruncatedDinoSour/{flask.request.full_path[3:]}", + code=302, + ) + + +@views.get("/lh", defaults={"_": ""}) +@views.get("/lh/", defaults={"_": ""}) +@views.get("/lh/") +def lh(_: str) -> Response: + """Main git organization account""" + return flask.redirect( + f"https://github.com/ari-lt/{flask.request.full_path[3:]}", + code=302, + ) diff --git a/src/static/css/base.css b/src/static/css/base.css index 14a1d92..a2a8ede 100644 --- a/src/static/css/base.css +++ b/src/static/css/base.css @@ -94,7 +94,7 @@ li { margin: 0.5em 0; } -p, table, table * { +table, table * { text-align: justify; } diff --git a/src/static/css/index.css b/src/static/css/index.css index 83c7e29..c3017b3 100644 --- a/src/static/css/index.css +++ b/src/static/css/index.css @@ -5,28 +5,41 @@ } .split { + display: -ms-grid; display: grid; + -ms-grid-columns: 3fr 0.5em 1fr; grid-template-columns: 3fr 1fr; grid-gap: 0.5em; + -webkit-box-align: stretch; + -ms-flex-align: stretch; align-items: stretch; height: 100%; } .split > * { + display: -webkit-box; + display: -ms-flexbox; display: flex; + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -ms-flex-direction: column; flex-direction: column; + -webkit-box-pack: center; + -ms-flex-pack: center; justify-content: center; padding: 2em; height: 100%; } .esplit { + -ms-grid-columns: 1fr 1fr; grid-template-columns: 1fr 1fr; } .split > :first-child { position: relative; overflow: hidden; + text-align: justify; } .split > :first-child::before { @@ -37,6 +50,7 @@ top: 0; bottom: 0; border-right: 2px solid var(--red); + -webkit-animation: grow 0.5s forwards; animation: grow 0.5s forwards; pointer-events: none; } @@ -68,9 +82,11 @@ form > button { } .form-group { + display: -ms-grid; display: grid; + -ms-grid-columns: 8rem 1rem auto; grid-template-columns: 8em auto; - grid-gap: 1em; + grid-gap: 1rem; margin: 0.4em; } @@ -86,6 +102,7 @@ form > button { } .captcha { + display: -ms-grid; display: grid; place-items: center; margin: 0.5em; @@ -100,36 +117,59 @@ form > button { background-color: #111; padding: 0.5em; margin: 0.5em; + width: 100%; } -#comments > div button { - border: none; - background-color: #000; - cursor: pointer; - border-bottom: 1px solid var(--red); +@-webkit-keyframes grow { + from { + -webkit-transform: scaleY(0); + transform: scaleY(0); + } + to { + -webkit-transform: scaleY(1); + transform: scaleY(1); + } } @keyframes grow { from { + -webkit-transform: scaleY(0); transform: scaleY(0); } to { + -webkit-transform: scaleY(1); transform: scaleY(1); } } @media only screen and (max-width: 1250px) { .split { + -ms-grid-columns: 1fr; grid-template-columns: 1fr; + -ms-grid-rows: auto 0.5em auto; grid-template-rows: auto auto; + -webkit-box-align: unset; + -ms-flex-align: unset; align-items: unset; } + .split > *:nth-child(1) { + -ms-grid-row: 1; + -ms-grid-column: 1; + } + .split > *:nth-child(2) { + -ms-grid-row: 3; + -ms-grid-column: 1; + } .split > :first-child { + -webkit-box-ordinal-group: 3; + -ms-flex-order: 2; order: 2; } .split > :last-child { + -webkit-box-ordinal-group: 2; + -ms-flex-order: 1; order: 1; } diff --git a/src/static/js/index.js b/src/static/js/particles.js similarity index 99% rename from src/static/js/index.js rename to src/static/js/particles.js index 1576515..7b82c5a 100644 --- a/src/static/js/index.js +++ b/src/static/js/particles.js @@ -33,7 +33,7 @@ document.addEventListener("DOMContentLoaded", function () { canvas.height = window.innerHeight; let particles = []; - const particle_density = 1.5e-5; + const particle_density = 2e-5; let num_particles; const particle_size = 2; diff --git a/src/static/js/rc4.js b/src/static/js/rc4.js new file mode 100644 index 0000000..1da0be1 --- /dev/null +++ b/src/static/js/rc4.js @@ -0,0 +1,62 @@ +/* + * @licstart The following is the entire license notice for the JavaScript code in this file. + * + * Copyright (C) 2024 Ari Archer + * + * This file is part of ari.lt. + * + * The JavaScript code in this file is free software: you can redistribute it + * and/or modify it under the terms of the GNU Affero General Public License + * (AGPL) as published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. The code is distributed 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. + * + * As additional permission under AGPL version 3 section 7, you may distribute non-source + * (e.g., minimized or compacted) forms of that code without the copy of the AGPL normally + * required by section 4, provided you include this license notice and a URL through which + * recipients can access the Corresponding Source. + * + * @licend The above is the entire license notice for the JavaScript code in this file. + */ + +"use strict"; + +function rc4(b64_ct, b64_key) { + let ciphertext = base64_to_array_buffer(b64_ct); + let key = base64_to_array_buffer(b64_key); + + let S = Array(256); + let j = 0, + temp; + for (let i = 0; i < 256; ++i) S[i] = i; + for (let i = 0; i < 256; ++i) { + j = (j + S[i] + key[i % key.length]) % 256; + temp = S[i]; + S[i] = S[j]; + S[j] = temp; + } + + let i = 0; + j = 0; + let decrypted = new Uint8Array(ciphertext.length); + for (let y = 0; y < ciphertext.length; y++) { + i = (i + 1) % 256; + j = (j + S[i]) % 256; + temp = S[i]; + S[i] = S[j]; + S[j] = temp; + decrypted[y] = ciphertext[y] ^ S[(S[i] + S[j]) % 256]; + } + + return new TextDecoder().decode(decrypted); +} + +function base64_to_array_buffer(base64) { + let bin = window.atob(base64); + let bytes = new Uint8Array(bin.length); + + for (let idx = 0; idx < bin.length; ++idx) bytes[idx] = bin.charCodeAt(idx); + + return bytes; +} diff --git a/src/templates/base.j2 b/src/templates/base.j2 index ed048f3..53476ac 100644 --- a/src/templates/base.j2 +++ b/src/templates/base.j2 @@ -36,7 +36,19 @@ {% block body %}{% endblock %}
{% block header %}{% endblock %}
-
{% block main %}{% endblock %}
+
+ {% with messages = get_flashed_messages(with_categories=True) %} + {% if messages %} +
+ messages from the server + {% for category, message in messages %} +
{{ message | escape }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + {% block main %}{% endblock %} +
diff --git a/src/templates/index.j2 b/src/templates/index.j2 index bd8b899..f6e8d3a 100644 --- a/src/templates/index.j2 +++ b/src/templates/index.j2 @@ -38,7 +38,8 @@ //--> - + + {% endblock %} {% block body %} @@ -54,6 +55,7 @@ Navigation @@ -171,14 +173,42 @@ this is a good introduction to what I do, what skills I posses.

-

- Current status: -

-
-
This is a status
-Hello world
-Last updated: 2024-06-06 00:17:11 UTC
+
*** Current status ***
+
+
{{ status["status"] }}
+
+
Last updated: {{ status["last_updated"] }}
+
+ +

# Pages and redirects

+ +
+ + +
+

+ Ari.lt includes multiple pages and redirects which include various information. This is a manually collected list + of some important pages. If you want a full list of all pages see the sitemap.xml + which has all possible pages on this site in an XML format - mainly used by indexers. +

+ +

+ Ari.lt is still in the middle of migration to full-featured self-hosting, so some stuff might break + or change with time, although I try to keep backwards compatibility possible. +

+
@@ -194,10 +224,17 @@ Last updated: 2024-06-06 00:17:11 UTC
  • GitHub organization: ari-lt (org@ari.lt)
  • Matrix: @ari:ari.lt
  • Fediverse: @ari@ak.ari.lt
  • -
  • E-Mail: ari@ari.lt (GPG: 4FAD63E936B305906A6C4894A50D5B4B599AF8A2)
  • +
  • E-Mail: ari@ari.lt (GPG: 4FAD63E936B305906A6C4894A50D5B4B599AF8A2)
  • +

    Badges

    + +
      +
    • Normal: ari-web badge
    • +
    • Yellow: ari-web badge
    • +
    +

    Activity

      @@ -223,6 +260,13 @@ Last updated: 2024-06-06 00:17:11 UTC sometimes.

      +

      Support

      + + +

      # Staff

      @@ -306,7 +350,7 @@ Last updated: 2024-06-06 00:17:11 UTC Email server hosting Mailcow - Contact ari@ari.lt for custom domains (aggressive policy) + Contact ari@ari.lt for custom domains (aggressive policy) mail.ari.lt (register here) @@ -355,10 +399,11 @@ Last updated: 2024-06-06 00:17:11 UTC

      # Guestbook

      - If you want to interact with the community feel free to leave your comments here! You will require an - email address to prevent spam and impersonation, it will be listed, although not as text as it will - be encrypted server-side using RC4 (a fast, but insecure cipher) - with a 32-byte (256 bit) key. Check your mailbox when you comment as you will need to verify the comment. + If you want to interact with the community feel free to leave your comments here! + You will require an email address so to prevent spam and scams, although, to protect + you from spam and scam emails, I have implemented measures to not store your + email address as plain text. Press 'show email' to show the email address on any given + comment.

      @@ -378,8 +423,8 @@ Last updated: 2024-06-06 00:17:11 UTC
      - - + +
      @@ -389,30 +434,23 @@ Last updated: 2024-06-06 00:17:11 UTC
      - +
      -
      -
      Report any impersonation to the owner of this website: Ari Archer.
      -
      -

      #1: Cool Person (https://example.com/) <> at 2024-06-05 11:11:11 UTC says...

      -
      Cool website, but you should kill yourself!
      -Meow!
      -
      - -
      -

      #1: Cool Person (https://example.com/) <> at 2024-06-05 11:11:11 UTC says...

      -
      Cool website, but you should kill yourself!
      -Meow!
      + {% for comment in comments %} +
      +

      #{{ comment.id }}: {{ comment.name | escape }} {% if comment.website %} ({{ comment.website | escape }}) {% endif %} <show email> at says...

      +
      {{ comment.comment | markdown }}
      + {% endfor %}
      {% endblock %}