Compare commits

..
11 Commits
Author SHA1 Message Date
thuttu d048ef82b7 Add dark theme and secret key creation script 2026-09-18 23:03:05 +03:00
thuttu d1d594674f feat(admin): manage member account access
Allow administrators to freeze, unfreeze, and permanently delete member accounts with related content cleanup.
2026-09-01 20:09:04 +03:00
thuttu 9c4c506168 Add ability to hide posts from unregistered users. 2026-09-01 19:10:33 +03:00
thuttu 5dc1b28ffd Add code snippets to long-form stories. 2026-09-01 18:39:41 +03:00
thuttu 8ad292c793 Add feature to use Real name on journals 2026-09-01 18:29:18 +03:00
thuttu 65bebd19c5 feat(auth): add email verification flow 2026-09-01 08:47:09 +03:00
thuttu 2dc4dedb2b Add feature user can change mail address 2026-09-01 08:39:30 +03:00
thuttu f2ff00894e Add more things to todo list 2026-08-30 11:42:59 +03:00
thuttu 90f60aa229 Remove pycache from git 2026-08-30 11:08:42 +03:00
thuttu edc4e7be5d Remove pycache from git 2026-08-30 11:07:23 +03:00
thuttu 9540aea3be Add ability to use more pictures 2026-08-30 10:48:26 +03:00
25 changed files with 949 additions and 52 deletions
+37 -2
View File
@@ -4,7 +4,20 @@ A Dockerized publishing service for eternityproject.fi. It provides local accoun
## Run it ## Run it
1. Copy `.env.example` to `.env` and replace `SECRET_KEY` with a long random string. 1. Copy `.env.example` to `.env` and replace `SECRET_KEY` with a long random string. Generate one with:
```powershell
# Windows PowerShell
.\scripts\generate-secret-key.ps1
```
```sh
# Linux, macOS, or any POSIX shell
sh ./scripts/generate-secret-key.sh
```
Paste the printed value into `SECRET_KEY` in `.env`. The scripts only print a value; they never modify `.env`.
2. Build and start the service: 2. Build and start the service:
```sh ```sh
@@ -15,6 +28,28 @@ Open `http://localhost:8000`, create the first account, and publish a field note
Posts can include one JPEG, PNG, or WebP image up to 8 MB. Images are converted to optimized WebP files in the persistent Docker data volume; the article shows a compact preview that opens full-size when selected. Posts can include one JPEG, PNG, or WebP image up to 8 MB. Images are converted to optimized WebP files in the persistent Docker data volume; the article shows a compact preview that opens full-size when selected.
Long posts can also include multiple ordered step images. Upload them under **Step images**, then place `[[image:1]]`, `[[image:2]]`, and so on directly in the article text where each repair step needs an image. Each step image supports an optional caption and opens full-size when selected.
To add a code snippet, place it between triple-backtick fences. Add an optional language name after the opening fence to label the snippet:
````text
```python
print("hello, circuit")
```
````
Code is displayed as literal text and can be copied from the article view. Image markers written inside a code fence remain literal text.
Posts are public by default. Authors can select **Members only** while writing or editing to hide a post from visitors who are not signed in; MFA-verified members can still read these posts.
Administrators can freeze member accounts from **Accounts**. Frozen members cannot sign in and are signed out on their next protected request; administrators can unfreeze them later. Administrators can also permanently delete a member account. Deleting an account also permanently removes its posts, uploaded images, and outstanding reset or verification tokens.
## Theming
The site ships with a light theme and a dark "wasteland" variant. The **WASTELAND** / **DAYLIGHT** button in the navigation toggles between them and remembers the choice in the browser's local storage; an inline script applies the saved theme before the page renders to avoid a flash of the wrong theme. Colors for both variants live as CSS custom properties in [static/css/site.css](static/css/site.css), overridden under the `[data-theme="dark"]` selector.
A framework-agnostic copy of the theme is available under [themes/](themes/) for reuse on other web pages: [themes/eternity-theme.css](themes/eternity-theme.css) contains the core design system (header, buttons, hero, cards, forms, code blocks, flashes) with no Flask or Jinja dependencies, and [themes/demo.html](themes/demo.html) is a standalone starter template demonstrating the components and the dark-mode toggle.
## Accounts and MFA ## Accounts and MFA
The initial administrator account is `admin` with password `admin`, as requested for first-run access. Sign in, scan the displayed QR code with any iPhone or Android TOTP authenticator, and change this password before exposing the service to the internet. New registrations are held for approval in **Accounts**; accepted users must enroll a TOTP authenticator before they can publish. The initial administrator account is `admin` with password `admin`, as requested for first-run access. Sign in, scan the displayed QR code with any iPhone or Android TOTP authenticator, and change this password before exposing the service to the internet. New registrations are held for approval in **Accounts**; accepted users must enroll a TOTP authenticator before they can publish.
@@ -37,7 +72,7 @@ After it prints `ADMIN_PASSWORD_ROTATED_MFA_RESET`, sign in as `admin` with the
## Email and password recovery ## Email and password recovery
Registration now requires an email address. Members can change their password from the navigation. The sign-in page provides an email-based recovery link; it expires after one hour and can only be used once. Administrators can send the same recovery email to any approved member from **Accounts**. Registration requires a real name, email address, and confirmation link before the account can sign in. The real name is used as the published-by name, while existing accounts continue to use their handle until they set a profile name. Members can change their email address from the navigation; the current address remains active until the link sent to the new address is confirmed. Verification and password-reset links expire after 24 hours and one hour respectively, and can only be used once. Administrators can send the same recovery email to any approved member from **Accounts**.
All browser POST forms are protected by server-validated CSRF tokens. All browser POST forms are protected by server-validated CSRF tokens.
+8 -1
View File
@@ -9,4 +9,11 @@
- [x] Add CSRF protection to all state-changing forms. - [x] Add CSRF protection to all state-changing forms.
- [x] Add automated database backups and test restoration. - [x] Add automated database backups and test restoration.
- [x] Configure TLS reverse proxy and production domain for `eternityproject.fi`. - [x] Configure TLS reverse proxy and production domain for `eternityproject.fi`.
- [x] Add validated post image uploads with an in-article preview and click-to-expand view. - [x] Add validated post image uploads with an in-article preview and click-to-expand view.
- [x] Add ordered inline step images with captions for long-form repair and engineering posts.
- [x] Add code snippets to long-form stories.
- [x] Allow users to change their email addresses.
- [x] Add user email verification for registration and email-address changes.
- [x] Add user real names and use them as the published-by name.
- [x] Add ability to hide posts from unregistered users.
- [x] Add administrator controls to freeze and delete member accounts.
Binary file not shown.
+300 -30
View File
@@ -67,7 +67,8 @@ def init_db():
is_approved INTEGER NOT NULL DEFAULT 0, is_approved INTEGER NOT NULL DEFAULT 0,
role TEXT NOT NULL DEFAULT 'member', role TEXT NOT NULL DEFAULT 'member',
mfa_secret TEXT, mfa_secret TEXT,
mfa_enabled INTEGER NOT NULL DEFAULT 0 mfa_enabled INTEGER NOT NULL DEFAULT 0,
is_frozen INTEGER NOT NULL DEFAULT 0
); );
CREATE TABLE IF NOT EXISTS posts ( CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -78,6 +79,7 @@ def init_db():
category TEXT NOT NULL, category TEXT NOT NULL,
image_filename TEXT, image_filename TEXT,
author_id INTEGER NOT NULL, author_id INTEGER NOT NULL,
is_hidden INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
FOREIGN KEY (author_id) REFERENCES users(id) FOREIGN KEY (author_id) REFERENCES users(id)
@@ -91,6 +93,26 @@ def init_db():
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) FOREIGN KEY (user_id) REFERENCES users(id)
); );
CREATE TABLE IF NOT EXISTS email_verification_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
email_address TEXT NOT NULL,
token_hash TEXT UNIQUE NOT NULL,
token_type TEXT NOT NULL,
expires_at TEXT NOT NULL,
used_at TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS post_images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
post_id INTEGER NOT NULL,
filename TEXT UNIQUE NOT NULL,
caption TEXT NOT NULL DEFAULT '',
position INTEGER NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (post_id) REFERENCES posts(id)
);
""" """
) )
user_columns = {row["name"] for row in db.execute("PRAGMA table_info(users)")} user_columns = {row["name"] for row in db.execute("PRAGMA table_info(users)")}
@@ -100,13 +122,20 @@ def init_db():
"mfa_secret": "ALTER TABLE users ADD COLUMN mfa_secret TEXT", "mfa_secret": "ALTER TABLE users ADD COLUMN mfa_secret TEXT",
"mfa_enabled": "ALTER TABLE users ADD COLUMN mfa_enabled INTEGER NOT NULL DEFAULT 0", "mfa_enabled": "ALTER TABLE users ADD COLUMN mfa_enabled INTEGER NOT NULL DEFAULT 0",
"email": "ALTER TABLE users ADD COLUMN email TEXT", "email": "ALTER TABLE users ADD COLUMN email TEXT",
"email_verified": "ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0",
"real_name": "ALTER TABLE users ADD COLUMN real_name TEXT",
"is_frozen": "ALTER TABLE users ADD COLUMN is_frozen INTEGER NOT NULL DEFAULT 0",
} }
post_columns = {row["name"] for row in db.execute("PRAGMA table_info(posts)")} post_columns = {row["name"] for row in db.execute("PRAGMA table_info(posts)")}
for column, statement in migrations.items(): for column, statement in migrations.items():
if column not in user_columns: if column not in user_columns:
db.execute(statement) db.execute(statement)
if column == "email_verified":
db.execute("UPDATE users SET email_verified = 1 WHERE email IS NOT NULL")
if "image_filename" not in post_columns: if "image_filename" not in post_columns:
db.execute("ALTER TABLE posts ADD COLUMN image_filename TEXT") db.execute("ALTER TABLE posts ADD COLUMN image_filename TEXT")
if "is_hidden" not in post_columns:
db.execute("ALTER TABLE posts ADD COLUMN is_hidden INTEGER NOT NULL DEFAULT 0")
db.execute("CREATE UNIQUE INDEX IF NOT EXISTS users_email_unique ON users(email)") db.execute("CREATE UNIQUE INDEX IF NOT EXISTS users_email_unique ON users(email)")
# Preserve access for accounts created before approvals were introduced. # Preserve access for accounts created before approvals were introduced.
@@ -127,6 +156,11 @@ def login_required(view):
if "user_id" not in session or not session.get("mfa_verified"): if "user_id" not in session or not session.get("mfa_verified"):
flash("Sign in to continue.", "notice") flash("Sign in to continue.", "notice")
return redirect(url_for("login", next=request.path)) return redirect(url_for("login", next=request.path))
user = get_db().execute("SELECT is_frozen FROM users WHERE id = ?", (session["user_id"],)).fetchone()
if user is None or user["is_frozen"]:
session.clear()
flash("This account has been frozen by an administrator.", "error")
return redirect(url_for("login"))
return view(*args, **kwargs) return view(*args, **kwargs)
return wrapped_view return wrapped_view
@@ -162,14 +196,14 @@ def post_from_request(post_id=None):
excerpt = request.form.get("excerpt", "").strip() excerpt = request.form.get("excerpt", "").strip()
body = request.form.get("body", "").strip() body = request.form.get("body", "").strip()
category = request.form.get("category", "Engineering").strip() or "Engineering" category = request.form.get("category", "Engineering").strip() or "Engineering"
is_hidden = int(request.form.get("is_hidden") == "1")
if not title or not excerpt or not body: if not title or not excerpt or not body:
raise ValueError("Title, summary, and article body are required.") raise ValueError("Title, summary, and article body are required.")
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
return title, unique_slug(title, post_id), excerpt, body, category, now return title, unique_slug(title, post_id), excerpt, body, category, is_hidden, now
def save_post_image(): def save_post_image(image_file):
image_file = request.files.get("image")
if image_file is None or not image_file.filename: if image_file is None or not image_file.filename:
return None return None
try: try:
@@ -189,10 +223,61 @@ def save_post_image():
return filename return filename
def save_inline_images(post_id):
image_files = [image_file for image_file in request.files.getlist("images") if image_file.filename]
captions = request.form.getlist("image_captions")
if not image_files:
return
next_position = get_db().execute(
"SELECT COALESCE(MAX(position), 0) + 1 FROM post_images WHERE post_id = ?", (post_id,)
).fetchone()[0]
now = datetime.now(timezone.utc).isoformat()
for offset, image_file in enumerate(image_files):
filename = save_post_image(image_file)
caption = captions[offset].strip() if offset < len(captions) else ""
get_db().execute(
"INSERT INTO post_images (post_id, filename, caption, position, created_at) VALUES (?, ?, ?, ?, ?)",
(post_id, filename, caption, next_position + offset, now),
)
def article_blocks(body, images):
image_map = {str(index): image for index, image in enumerate(images, start=1)}
blocks = []
marker = re.compile(
r"```(?P<language>[A-Za-z0-9][A-Za-z0-9_-]*)?[ \t]*\r?\n(?P<code>.*?)(?:\r?\n)?```|\[\[image:(?P<image>\d+)\]\]",
re.DOTALL,
)
cursor = 0
for match in marker.finditer(body):
if match.start() > cursor:
blocks.append(("text", body[cursor:match.start()]))
if match.group("code") is not None:
blocks.append(("code", {"language": match.group("language") or "text", "content": match.group("code")}))
else:
image = image_map.get(match.group("image"))
if image is not None:
blocks.append(("image", image))
else:
blocks.append(("text", match.group(0)))
cursor = match.end()
if cursor < len(body):
blocks.append(("text", body[cursor:]))
return blocks
def valid_email(email): def valid_email(email):
return re.fullmatch(r"[^\s@]+@[^\s@]+\.[^\s@]+", email) is not None return re.fullmatch(r"[^\s@]+@[^\s@]+\.[^\s@]+", email) is not None
def normalize_real_name(real_name):
return " ".join(real_name.split())
def valid_real_name(real_name):
return 2 <= len(real_name) <= 100 and all(character.isalnum() or character in " .'-" for character in real_name)
def create_reset_token(user_id): def create_reset_token(user_id):
token = token_urlsafe(32) token = token_urlsafe(32)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
@@ -223,33 +308,69 @@ def send_reset_email(user):
client.send_message(message) client.send_message(message)
def create_email_verification_token(user_id, email_address, token_type):
token = token_urlsafe(32)
now = datetime.now(timezone.utc)
db = get_db()
db.execute(
"UPDATE email_verification_tokens SET used_at = ? WHERE user_id = ? AND token_type = ? AND used_at IS NULL",
(now.isoformat(), user_id, token_type),
)
db.execute(
"""INSERT INTO email_verification_tokens (user_id, email_address, token_hash, token_type, expires_at, created_at)
VALUES (?, ?, ?, ?, ?, ?)""",
(user_id, email_address, sha256(token.encode("utf-8")).hexdigest(), token_type, (now + timedelta(hours=24)).isoformat(), now.isoformat()),
)
db.commit()
return token
def send_verification_email(user, email_address, token_type):
if not app.config["MAIL_HOST"]:
raise RuntimeError("Email is not configured. Set MAIL_HOST and related SMTP settings.")
token = create_email_verification_token(user["id"], email_address, token_type)
verification_url = f"{app.config['PUBLIC_URL']}{url_for('verify_email', token=token)}"
action = "confirm this email address for your account" if token_type == "registration" else "confirm this new email address"
message = EmailMessage()
message["Subject"] = "Confirm your Eternity Project email address"
message["From"] = app.config["MAIL_FROM"]
message["To"] = email_address
message.set_content(f"Hello {user['username']},\n\nUse this link within 24 hours to {action}:\n{verification_url}\n\nIf you did not request this, you can ignore this email.")
with smtplib.SMTP(app.config["MAIL_HOST"], app.config["MAIL_PORT"]) as client:
client.starttls()
if app.config["MAIL_USERNAME"]:
client.login(app.config["MAIL_USERNAME"], app.config["MAIL_PASSWORD"])
client.send_message(message)
@app.context_processor @app.context_processor
def inject_current_user(): def inject_current_user():
user = None user = None
if "user_id" in session: if "user_id" in session:
user = get_db().execute("SELECT id, username, role FROM users WHERE id = ?", (session["user_id"],)).fetchone() user = get_db().execute("SELECT id, username, real_name, role FROM users WHERE id = ?", (session["user_id"],)).fetchone()
return {"current_user": user} return {"current_user": user}
@app.route("/") @app.route("/")
def index(): def index():
posts = get_db().execute( query = """SELECT posts.*, users.username, users.real_name FROM posts JOIN users ON users.id = posts.author_id"""
"""SELECT posts.*, users.username FROM posts JOIN users ON users.id = posts.author_id if not session.get("mfa_verified"):
ORDER BY posts.created_at DESC""" query += " WHERE posts.is_hidden = 0"
).fetchall() posts = get_db().execute(f"{query} ORDER BY posts.created_at DESC").fetchall()
return render_template("index.html", posts=posts) return render_template("index.html", posts=posts)
@app.route("/post/<slug>") @app.route("/post/<slug>")
def post(slug): def post(slug):
article = get_db().execute( query = """SELECT posts.*, users.username, users.real_name FROM posts JOIN users ON users.id = posts.author_id
"""SELECT posts.*, users.username FROM posts JOIN users ON users.id = posts.author_id WHERE posts.slug = ?"""
WHERE posts.slug = ?""", if not session.get("mfa_verified"):
(slug,), query += " AND posts.is_hidden = 0"
).fetchone() article = get_db().execute(query, (slug,)).fetchone()
if article is None: if article is None:
abort(404) abort(404)
return render_template("post.html", post=article) images = get_db().execute("SELECT * FROM post_images WHERE post_id = ? ORDER BY position", (article["id"],)).fetchall()
return render_template("post.html", post=article, blocks=article_blocks(article["body"], images))
@app.get("/uploads/<path:filename>") @app.get("/uploads/<path:filename>")
@@ -261,22 +382,31 @@ def uploaded_image(filename):
def register(): def register():
if request.method == "POST": if request.method == "POST":
username = request.form.get("username", "").strip().lower() username = request.form.get("username", "").strip().lower()
real_name = normalize_real_name(request.form.get("real_name", ""))
email = request.form.get("email", "").strip().lower() email = request.form.get("email", "").strip().lower()
password = request.form.get("password", "") password = request.form.get("password", "")
if not re.fullmatch(r"[a-z0-9_-]{3,32}", username): if not re.fullmatch(r"[a-z0-9_-]{3,32}", username):
flash("Use 3-32 lowercase letters, numbers, hyphens, or underscores.", "error") flash("Use 3-32 lowercase letters, numbers, hyphens, or underscores.", "error")
elif not valid_real_name(real_name):
flash("Enter a real name using 2-100 letters, numbers, spaces, hyphens, apostrophes, or periods.", "error")
elif len(password) < 10: elif len(password) < 10:
flash("Choose a password with at least 10 characters.", "error") flash("Choose a password with at least 10 characters.", "error")
elif not valid_email(email): elif not valid_email(email):
flash("Enter a valid email address.", "error") flash("Enter a valid email address.", "error")
else: else:
try: try:
get_db().execute( cursor = get_db().execute(
"INSERT INTO users (username, email, password_hash, created_at, is_approved) VALUES (?, ?, ?, ?, 0)", "INSERT INTO users (username, real_name, email, password_hash, created_at, is_approved) VALUES (?, ?, ?, ?, ?, 0)",
(username, email, generate_password_hash(password), datetime.now(timezone.utc).isoformat()), (username, real_name, email, generate_password_hash(password), datetime.now(timezone.utc).isoformat()),
) )
get_db().commit() get_db().commit()
flash("Registration received. An administrator must approve it before you can sign in.", "success") user = {"id": cursor.lastrowid, "username": username}
try:
send_verification_email(user, email, "registration")
flash("Check your email to verify your address. An administrator must also approve your account before you can sign in.", "success")
except (RuntimeError, OSError, smtplib.SMTPException):
app.logger.exception("Unable to send registration verification email")
flash("Registration received, but the verification email could not be sent. Contact an administrator.", "error")
return redirect(url_for("login")) return redirect(url_for("login"))
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
flash("That handle or email address is already in use.", "error") flash("That handle or email address is already in use.", "error")
@@ -290,8 +420,12 @@ def login():
user = get_db().execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone() user = get_db().execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone()
if user is None or not check_password_hash(user["password_hash"], request.form.get("password", "")): if user is None or not check_password_hash(user["password_hash"], request.form.get("password", "")):
flash("Invalid handle or password.", "error") flash("Invalid handle or password.", "error")
elif user["is_frozen"]:
flash("This account has been frozen by an administrator.", "error")
elif not user["is_approved"]: elif not user["is_approved"]:
flash("Your account is awaiting administrator approval.", "notice") flash("Your account is awaiting administrator approval.", "notice")
elif user["email"] and not user["email_verified"]:
flash("Verify your email address before you can sign in.", "notice")
else: else:
session.clear() session.clear()
session["mfa_pending_user_id"] = user["id"] session["mfa_pending_user_id"] = user["id"]
@@ -302,6 +436,52 @@ def login():
return render_template("auth.html", mode="login") return render_template("auth.html", mode="login")
@app.route("/email/resend", methods=("GET", "POST"))
def resend_verification_email():
if request.method == "POST":
username = request.form.get("username", "").strip().lower()
user = get_db().execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone()
if user is not None and user["email"] and not user["email_verified"] and check_password_hash(user["password_hash"], request.form.get("password", "")):
try:
send_verification_email(user, user["email"], "registration")
except (RuntimeError, OSError, smtplib.SMTPException):
app.logger.exception("Unable to resend verification email")
flash("If those credentials belong to an unverified account, a verification link has been sent.", "success")
return redirect(url_for("login"))
return render_template("auth.html", mode="resend")
@app.route("/email/verify/<token>", methods=("GET", "POST"))
def verify_email(token):
now = datetime.now(timezone.utc).isoformat()
verification = get_db().execute(
"""SELECT * FROM email_verification_tokens
WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?""",
(sha256(token.encode("utf-8")).hexdigest(), now),
).fetchone()
if verification is None:
flash("That email verification link is invalid or has expired.", "error")
return redirect(url_for("login"))
if request.method == "POST":
db = get_db()
try:
if verification["token_type"] == "registration":
db.execute("UPDATE users SET email_verified = 1 WHERE id = ?", (verification["user_id"],))
message = "Email verified. You can sign in once an administrator approves your account."
else:
db.execute("UPDATE users SET email = ?, email_verified = 1 WHERE id = ?", (verification["email_address"], verification["user_id"]))
message = "Email address updated and verified."
db.execute("UPDATE email_verification_tokens SET used_at = ? WHERE id = ?", (now, verification["id"]))
db.commit()
except sqlite3.IntegrityError:
db.rollback()
flash("That email address is already in use. Request a verification link for a different address.", "error")
return redirect(url_for("change_email"))
flash(message, "success")
return redirect(url_for("login"))
return render_template("email_verify.html", token=token, email=verification["email_address"], token_type=verification["token_type"])
@app.route("/password/forgot", methods=("GET", "POST")) @app.route("/password/forgot", methods=("GET", "POST"))
def forgot_password(): def forgot_password():
if request.method == "POST": if request.method == "POST":
@@ -360,6 +540,50 @@ def change_password():
return render_template("password_form.html", mode="change") return render_template("password_form.html", mode="change")
@app.route("/account/profile", methods=("GET", "POST"))
@login_required
def change_profile():
user = get_db().execute("SELECT real_name FROM users WHERE id = ?", (session["user_id"],)).fetchone()
if request.method == "POST":
real_name = normalize_real_name(request.form.get("real_name", ""))
if not valid_real_name(real_name):
flash("Enter a real name using 2-100 letters, numbers, spaces, hyphens, apostrophes, or periods.", "error")
else:
get_db().execute("UPDATE users SET real_name = ? WHERE id = ?", (real_name, session["user_id"]))
get_db().commit()
flash("Profile updated.", "success")
return redirect(url_for("index"))
return render_template("profile_form.html", real_name=user["real_name"] or "")
@app.route("/account/email", methods=("GET", "POST"))
@login_required
def change_email():
user = get_db().execute("SELECT id, username, email, password_hash FROM users WHERE id = ?", (session["user_id"],)).fetchone()
if request.method == "POST":
email = request.form.get("email", "").strip().lower()
current_password = request.form.get("current_password", "")
if not check_password_hash(user["password_hash"], current_password):
flash("Your current password is incorrect.", "error")
elif not valid_email(email):
flash("Enter a valid email address.", "error")
elif email == user["email"]:
flash("That is already your email address.", "notice")
else:
try:
existing_user = get_db().execute("SELECT id FROM users WHERE email = ? AND id != ?", (email, session["user_id"])).fetchone()
if existing_user is not None:
flash("That email address is already in use.", "error")
return render_template("email_form.html", email=user["email"])
send_verification_email(user, email, "email_change")
flash("Verification email sent. Confirm the new address within 24 hours to complete the change.", "success")
return redirect(url_for("index"))
except (RuntimeError, OSError, smtplib.SMTPException):
app.logger.exception("Unable to send email change verification email")
flash("Verification email could not be sent. Check SMTP settings and try again.", "error")
return render_template("email_form.html", email=user["email"])
@app.post("/logout") @app.post("/logout")
def logout(): def logout():
session.clear() session.clear()
@@ -422,7 +646,7 @@ def mfa_verify():
@admin_required @admin_required
def admin_users(): def admin_users():
users = get_db().execute( users = get_db().execute(
"SELECT id, username, email, created_at, is_approved, mfa_enabled, role FROM users ORDER BY is_approved, created_at DESC" "SELECT id, username, real_name, email, email_verified, created_at, is_approved, mfa_enabled, is_frozen, role FROM users ORDER BY is_frozen DESC, is_approved, created_at DESC"
).fetchall() ).fetchall()
return render_template("admin_users.html", users=users) return render_template("admin_users.html", users=users)
@@ -436,6 +660,48 @@ def approve_user(user_id):
return redirect(url_for("admin_users")) return redirect(url_for("admin_users"))
@app.post("/admin/users/<int:user_id>/freeze")
@admin_required
def freeze_user(user_id):
cursor = get_db().execute("UPDATE users SET is_frozen = 1 WHERE id = ? AND role = 'member' AND is_frozen = 0", (user_id,))
get_db().commit()
flash("Account frozen." if cursor.rowcount else "No active member account was changed.", "success")
return redirect(url_for("admin_users"))
@app.post("/admin/users/<int:user_id>/unfreeze")
@admin_required
def unfreeze_user(user_id):
cursor = get_db().execute("UPDATE users SET is_frozen = 0 WHERE id = ? AND role = 'member' AND is_frozen = 1", (user_id,))
get_db().commit()
flash("Account unfrozen." if cursor.rowcount else "No frozen member account was changed.", "success")
return redirect(url_for("admin_users"))
@app.post("/admin/users/<int:user_id>/delete")
@admin_required
def delete_user(user_id):
db = get_db()
user = db.execute("SELECT id, username FROM users WHERE id = ? AND role = 'member'", (user_id,)).fetchone()
if user is None:
abort(404)
image_rows = db.execute(
"""SELECT image_filename AS filename FROM posts WHERE author_id = ? AND image_filename IS NOT NULL
UNION SELECT post_images.filename FROM post_images JOIN posts ON posts.id = post_images.post_id WHERE posts.author_id = ?""",
(user_id, user_id),
).fetchall()
db.execute("DELETE FROM post_images WHERE post_id IN (SELECT id FROM posts WHERE author_id = ?)", (user_id,))
db.execute("DELETE FROM posts WHERE author_id = ?", (user_id,))
db.execute("DELETE FROM password_reset_tokens WHERE user_id = ?", (user_id,))
db.execute("DELETE FROM email_verification_tokens WHERE user_id = ?", (user_id,))
db.execute("DELETE FROM users WHERE id = ?", (user_id,))
db.commit()
for image in image_rows:
(app.config["UPLOAD_FOLDER"] / image["filename"]).unlink(missing_ok=True)
flash(f"Account {user['username']} and its posts were permanently deleted.", "success")
return redirect(url_for("admin_users"))
@app.post("/admin/users/<int:user_id>/password-reset") @app.post("/admin/users/<int:user_id>/password-reset")
@admin_required @admin_required
def admin_password_reset(user_id): def admin_password_reset(user_id):
@@ -457,13 +723,15 @@ def admin_password_reset(user_id):
def write(): def write():
if request.method == "POST": if request.method == "POST":
try: try:
title, slug, excerpt, body, category, now = post_from_request() title, slug, excerpt, body, category, is_hidden, now = post_from_request()
image_filename = save_post_image() image_file = request.files.get("image")
get_db().execute( image_filename = save_post_image(image_file) if image_file and image_file.filename else None
"""INSERT INTO posts (title, slug, excerpt, body, category, image_filename, author_id, created_at, updated_at) cursor = get_db().execute(
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", """INSERT INTO posts (title, slug, excerpt, body, category, image_filename, author_id, is_hidden, created_at, updated_at)
(title, slug, excerpt, body, category, image_filename, session["user_id"], now, now), VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(title, slug, excerpt, body, category, image_filename, session["user_id"], is_hidden, now, now),
) )
save_inline_images(cursor.lastrowid)
get_db().commit() get_db().commit()
return redirect(url_for("post", slug=slug)) return redirect(url_for("post", slug=slug))
except ValueError as error: except ValueError as error:
@@ -481,12 +749,14 @@ def edit_post(slug):
abort(403) abort(403)
if request.method == "POST": if request.method == "POST":
try: try:
title, new_slug, excerpt, body, category, now = post_from_request(article["id"]) title, new_slug, excerpt, body, category, is_hidden, now = post_from_request(article["id"])
image_filename = save_post_image() or article["image_filename"] image_file = request.files.get("image")
image_filename = save_post_image(image_file) if image_file and image_file.filename else article["image_filename"]
get_db().execute( get_db().execute(
"""UPDATE posts SET title=?, slug=?, excerpt=?, body=?, category=?, image_filename=?, updated_at=? WHERE id=?""", """UPDATE posts SET title=?, slug=?, excerpt=?, body=?, category=?, image_filename=?, is_hidden=?, updated_at=? WHERE id=?""",
(title, new_slug, excerpt, body, category, image_filename, now, article["id"]), (title, new_slug, excerpt, body, category, image_filename, is_hidden, now, article["id"]),
) )
save_inline_images(article["id"])
get_db().commit() get_db().commit()
if image_filename != article["image_filename"] and article["image_filename"]: if image_filename != article["image_filename"] and article["image_filename"]:
(app.config["UPLOAD_FOLDER"] / article["image_filename"]).unlink(missing_ok=True) (app.config["UPLOAD_FOLDER"] / article["image_filename"]).unlink(missing_ok=True)
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
# Prints a cryptographically random SECRET_KEY value; does not modify .env.
$bytes = [byte[]]::new(32)
$rng = [System.Security.Cryptography.RNGCryptoServiceProvider]::new()
try {
$rng.GetBytes($bytes)
} finally {
$rng.Dispose()
}
($bytes | ForEach-Object { $_.ToString("x2") }) -join ""
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
set -eu
# Prints a cryptographically random SECRET_KEY value; does not modify .env.
if command -v python3 >/dev/null 2>&1; then
python3 -c 'import secrets; print(secrets.token_hex(32))'
elif command -v openssl >/dev/null 2>&1; then
openssl rand -hex 32
else
printf '%s\n' "Neither python3 nor openssl is available to generate a secret." >&2
exit 1
fi
+14 -7
View File
@@ -1,8 +1,11 @@
:root { --ink:#11212b; --paper:#f1f1e9; --acid:#c7ef4b; --orange:#ff6b35; --line:#b9c5bc; --muted:#59706b; } :root { --ink:#11212b; --paper:#f1f1e9; --acid:#c7ef4b; --orange:#ff6b35; --line:#b9c5bc; --muted:#59706b; --panel:rgba(241,241,233,.8); --field:#fbfbf6; --backdrop:rgba(17,33,43,.85); --flash-error:#ffe3d9; --grid:17,33,43; --grid-a1:.05; --grid-a2:.04; }
[data-theme="dark"] { --ink:#e7e2d3; --paper:#161210; --acid:#8fae3d; --orange:#e4632c; --line:#4a3f33; --muted:#a89a86; --panel:rgba(22,18,16,.82); --field:#211b17; --backdrop:rgba(8,6,5,.9); --flash-error:#3a1c14; --grid:228,98,44; --grid-a1:.07; --grid-a2:.05; }
* { box-sizing:border-box; } * { box-sizing:border-box; }
html { scroll-behavior:smooth; } html { scroll-behavior:smooth; }
body { margin:0; background:var(--paper); color:var(--ink); font-family:"Space Grotesk", sans-serif; } body { margin:0; background:var(--paper); color:var(--ink); font-family:"Space Grotesk", sans-serif; transition:background .2s, color .2s; }
body::before { content:""; position:fixed; inset:0; pointer-events:none; opacity:.4; background-image:linear-gradient(90deg, transparent 49.5%, rgba(17,33,43,.05) 50%, transparent 50.5%),linear-gradient(rgba(17,33,43,.04) 1px, transparent 1px); background-size:70px 70px, 7px 7px; } body::before { content:""; position:fixed; inset:0; pointer-events:none; opacity:.4; background-image:linear-gradient(90deg, transparent 49.5%, rgba(var(--grid),var(--grid-a1)) 50%, transparent 50.5%),linear-gradient(rgba(var(--grid),var(--grid-a2)) 1px, transparent 1px); background-size:70px 70px, 7px 7px; }
[data-theme="dark"] body::after { content:""; position:fixed; inset:0; pointer-events:none; opacity:.5; mix-blend-mode:overlay; background-image:radial-gradient(circle at 12% 20%, rgba(143,174,61,.12), transparent 35%), radial-gradient(circle at 85% 75%, rgba(228,98,44,.14), transparent 40%); }
.theme-toggle { border:1px solid var(--ink); background:transparent; color:inherit; padding:6px 9px; font:11px "DM Mono", monospace; cursor:pointer; letter-spacing:.5px; }.theme-toggle:hover { background:var(--acid); color:var(--paper); }
a { color:inherit; text-decoration:none; } .site-header, main, footer { position:relative; z-index:1; } a { color:inherit; text-decoration:none; } .site-header, main, footer { position:relative; z-index:1; }
.site-header { min-height:76px; padding:16px max(5vw, 24px); border-bottom:1px solid var(--ink); display:flex; align-items:center; justify-content:space-between; gap:24px; } .site-header { min-height:76px; padding:16px max(5vw, 24px); border-bottom:1px solid var(--ink); display:flex; align-items:center; justify-content:space-between; gap:24px; }
.brand { display:flex; gap:10px; align-items:center; font-family:"DM Mono", monospace; line-height:1; }.brand strong { display:block; letter-spacing:1px; font-size:16px; }.brand small { font-size:9px; color:var(--muted); } .brand { display:flex; gap:10px; align-items:center; font-family:"DM Mono", monospace; line-height:1; }.brand strong { display:block; letter-spacing:1px; font-size:16px; }.brand small { font-size:9px; color:var(--muted); }
@@ -11,8 +14,12 @@ nav { display:flex; align-items:center; gap:22px; font-family:"DM Mono", monospa
.button { display:inline-flex; align-items:center; justify-content:center; gap:18px; background:var(--ink); color:var(--paper); padding:13px 17px; border:1px solid var(--ink); font:500 13px "DM Mono", monospace; cursor:pointer; }.button:hover { background:var(--acid); color:var(--ink); }.compact { padding:9px 12px; } .button { display:inline-flex; align-items:center; justify-content:center; gap:18px; background:var(--ink); color:var(--paper); padding:13px 17px; border:1px solid var(--ink); font:500 13px "DM Mono", monospace; cursor:pointer; }.button:hover { background:var(--acid); color:var(--ink); }.compact { padding:9px 12px; }
.hero { min-height:620px; padding:clamp(70px, 12vw, 150px) max(5vw, 24px) 75px; border-bottom:1px solid var(--ink); position:relative; overflow:hidden; }.signal-label, .section-head, .post-meta, .byline { font:11px "DM Mono", monospace; letter-spacing:.7px; }.signal-label { color:var(--orange); }.hero h1 { max-width:820px; margin:20px 0; font-size:clamp(52px, 9vw, 132px); line-height:.83; letter-spacing:0; font-weight:600; }.hero h1 em { font-family:Georgia, serif; font-weight:400; }.hero p { max-width:510px; color:var(--muted); font-size:18px; line-height:1.45; margin:28px 0; }.scope { position:absolute; right:8%; bottom:-95px; width:310px; height:310px; border:2px solid var(--orange); border-radius:50%; display:grid; place-items:center; font:100px Georgia, serif; color:var(--orange); }.scope span { position:absolute; width:100%; height:1px; background:var(--orange); transform:rotate(45deg); }.scope span:nth-child(2){ transform:rotate(90deg) }.scope span:nth-child(3){ transform:rotate(135deg) } .hero { min-height:620px; padding:clamp(70px, 12vw, 150px) max(5vw, 24px) 75px; border-bottom:1px solid var(--ink); position:relative; overflow:hidden; }.signal-label, .section-head, .post-meta, .byline { font:11px "DM Mono", monospace; letter-spacing:.7px; }.signal-label { color:var(--orange); }.hero h1 { max-width:820px; margin:20px 0; font-size:clamp(52px, 9vw, 132px); line-height:.83; letter-spacing:0; font-weight:600; }.hero h1 em { font-family:Georgia, serif; font-weight:400; }.hero p { max-width:510px; color:var(--muted); font-size:18px; line-height:1.45; margin:28px 0; }.scope { position:absolute; right:8%; bottom:-95px; width:310px; height:310px; border:2px solid var(--orange); border-radius:50%; display:grid; place-items:center; font:100px Georgia, serif; color:var(--orange); }.scope span { position:absolute; width:100%; height:1px; background:var(--orange); transform:rotate(45deg); }.scope span:nth-child(2){ transform:rotate(90deg) }.scope span:nth-child(3){ transform:rotate(135deg) }
.journal, .editor-wrap { padding:40px max(5vw, 24px) 90px; }.section-head { display:flex; justify-content:space-between; padding-bottom:14px; border-bottom:1px solid var(--ink); margin-bottom:18px; }.post-grid { display:grid; grid-template-columns:repeat(3, 1fr); border-top:1px solid var(--ink); border-left:1px solid var(--ink); }.post-card { min-height:320px; padding:21px; border-right:1px solid var(--ink); border-bottom:1px solid var(--ink); display:flex; flex-direction:column; transition:background .2s; }.post-card:hover { background:var(--acid); }.post-meta { display:flex; justify-content:space-between; color:var(--muted); }.post-card h2 { font-size:29px; line-height:1; margin:28px 0 14px; }.post-card h2 a:hover { text-decoration:underline; text-decoration-thickness:2px; }.post-card p { line-height:1.45; margin:0; color:var(--muted); }.post-foot { margin-top:auto; display:flex; justify-content:space-between; align-items:center; padding-top:20px; font:10px "DM Mono", monospace; }.post-foot a { font:30px "DM Mono", monospace; }.empty-state { min-height:300px; padding:64px 0; text-align:center; border-bottom:1px solid var(--ink); }.empty-icon { font:42px "DM Mono", monospace; color:var(--orange); }.empty-state h2 { margin:10px 0 0; font-size:31px; }.empty-state p { color:var(--muted); margin:8px 0 24px; } .journal, .editor-wrap { padding:40px max(5vw, 24px) 90px; }.section-head { display:flex; justify-content:space-between; padding-bottom:14px; border-bottom:1px solid var(--ink); margin-bottom:18px; }.post-grid { display:grid; grid-template-columns:repeat(3, 1fr); border-top:1px solid var(--ink); border-left:1px solid var(--ink); }.post-card { min-height:320px; padding:21px; border-right:1px solid var(--ink); border-bottom:1px solid var(--ink); display:flex; flex-direction:column; transition:background .2s; }.post-card:hover { background:var(--acid); }.post-meta { display:flex; justify-content:space-between; color:var(--muted); }.post-card h2 { font-size:29px; line-height:1; margin:28px 0 14px; }.post-card h2 a:hover { text-decoration:underline; text-decoration-thickness:2px; }.post-card p { line-height:1.45; margin:0; color:var(--muted); }.post-foot { margin-top:auto; display:flex; justify-content:space-between; align-items:center; padding-top:20px; font:10px "DM Mono", monospace; }.post-foot a { font:30px "DM Mono", monospace; }.empty-state { min-height:300px; padding:64px 0; text-align:center; border-bottom:1px solid var(--ink); }.empty-icon { font:42px "DM Mono", monospace; color:var(--orange); }.empty-state h2 { margin:10px 0 0; font-size:31px; }.empty-state p { color:var(--muted); margin:8px 0 24px; }
.auth-layout { min-height:calc(100vh - 160px); padding:70px max(10vw, 24px); display:grid; grid-template-columns:1fr minmax(280px, 390px); gap:10vw; align-items:center; }.auth-copy h1 { font-size:clamp(45px, 6vw, 82px); line-height:.9; margin:15px 0; }.auth-copy p { max-width:380px; font-size:18px; line-height:1.5; color:var(--muted); }.auth-form, .editor-form { display:grid; gap:19px; }.auth-form { padding:27px; border:1px solid var(--ink); background:rgba(241,241,233,.8); }.auth-form label, .editor-form label { display:grid; gap:7px; font:11px "DM Mono", monospace; }input, textarea { width:100%; resize:vertical; background:#fbfbf6; border:1px solid var(--ink); border-radius:0; padding:12px; color:var(--ink); font:15px "Space Grotesk", sans-serif; }input:focus, textarea:focus { outline:3px solid var(--acid); outline-offset:1px; }.form-switch { font-size:13px; color:var(--muted); }.form-switch a { color:var(--ink); text-decoration:underline; }.editor-form { max-width:900px; }.two-col { display:grid; grid-template-columns:1fr 2fr; gap:19px; } .auth-layout { min-height:calc(100vh - 160px); padding:70px max(10vw, 24px); display:grid; grid-template-columns:1fr minmax(280px, 390px); gap:10vw; align-items:center; }.auth-copy h1 { font-size:clamp(45px, 6vw, 82px); line-height:.9; margin:15px 0; }.auth-copy p { max-width:380px; font-size:18px; line-height:1.5; color:var(--muted); }.auth-form, .editor-form { display:grid; gap:19px; }.auth-form { padding:27px; border:1px solid var(--ink); background:var(--panel); }.auth-form label, .editor-form label { display:grid; gap:7px; font:11px "DM Mono", monospace; }input, textarea { width:100%; resize:vertical; background:var(--field); border:1px solid var(--ink); border-radius:0; padding:12px; color:var(--ink); font:15px "Space Grotesk", sans-serif; }input:focus, textarea:focus { outline:3px solid var(--acid); outline-offset:1px; }.form-switch { font-size:13px; color:var(--muted); }.form-switch a { color:var(--ink); text-decoration:underline; }.editor-form { max-width:900px; }.two-col { display:grid; grid-template-columns:1fr 2fr; gap:19px; }
.article { max-width:850px; margin:0 auto; padding:80px 24px 100px; }.article h1 { font-size:clamp(48px, 7vw, 90px); line-height:.93; margin:23px 0; }.article-lead { border-left:4px solid var(--orange); padding-left:18px; max-width:720px; font-size:23px; line-height:1.35; }.byline { padding:20px 0; border-top:1px solid var(--ink); border-bottom:1px solid var(--ink); margin:35px 0; }.article-image-button { display:block; max-width:680px; width:100%; margin:0 0 30px; padding:0; border:0; background:none; cursor:zoom-in; }.article-image { display:block; width:100%; max-height:430px; object-fit:cover; border:1px solid var(--ink); }.image-dialog { width:min(94vw, 1200px); max-width:none; padding:0; border:1px solid var(--ink); background:var(--ink); }.image-dialog::backdrop { background:rgba(17,33,43,.85); }.image-dialog img { display:block; width:100%; max-height:88vh; object-fit:contain; }.dialog-close { position:absolute; top:10px; right:10px; border:1px solid var(--paper); background:var(--ink); color:var(--paper); padding:8px 10px; font:12px "DM Mono", monospace; cursor:pointer; }.article-body { max-width:680px; white-space:pre-wrap; font-size:18px; line-height:1.7; margin-bottom:35px; } .step-images { margin:0; padding:16px; border:1px solid var(--ink); }.step-images legend { padding:0 5px; font:11px "DM Mono", monospace; }.step-images p { margin:0 0 15px; color:var(--muted); font-size:13px; line-height:1.45; }.step-images code { color:var(--orange); font:12px "DM Mono", monospace; }.step-image-row { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-bottom:12px; }.add-step-image { justify-self:start; border:1px solid var(--ink); background:transparent; padding:8px 10px; font:11px "DM Mono", monospace; cursor:pointer; }.add-step-image:hover { background:var(--acid); }
.flash { margin:16px max(5vw, 24px) 0; padding:11px 14px; font:12px "DM Mono", monospace; border:1px solid var(--ink); }.flash.error { border-color:var(--orange); background:#ffe3d9; }.flash.success { background:var(--acid); }footer { border-top:1px solid var(--ink); padding:23px max(5vw, 24px); display:flex; justify-content:space-between; font:10px "DM Mono", monospace; color:var(--muted); } .post-visibility { grid-template-columns:auto 1fr; align-items:center; column-gap:9px; padding:12px; border:1px solid var(--ink); background:var(--panel); cursor:pointer; }.post-visibility input { width:16px; height:16px; margin:0; accent-color:var(--orange); }.post-visibility small { grid-column:2; color:var(--muted); font:12px "Space Grotesk", sans-serif; letter-spacing:0; text-transform:none; }
.article { max-width:850px; margin:0 auto; padding:80px 24px 100px; }.article h1 { font-size:clamp(48px, 7vw, 90px); line-height:.93; margin:23px 0; }.article-lead { border-left:4px solid var(--orange); padding-left:18px; max-width:720px; font-size:23px; line-height:1.35; }.byline { padding:20px 0; border-top:1px solid var(--ink); border-bottom:1px solid var(--ink); margin:35px 0; }.article-image-button { display:block; max-width:680px; width:100%; margin:0 0 30px; padding:0; border:0; background:none; cursor:zoom-in; }.article-image { display:block; width:100%; max-height:430px; object-fit:cover; border:1px solid var(--ink); }.inline-image { max-width:680px; margin:28px 0; }.inline-image .article-image-button { margin:0; }.inline-image figcaption { padding-top:8px; color:var(--muted); font:12px "DM Mono", monospace; }.image-dialog { width:min(94vw, 1200px); max-width:none; padding:0; border:1px solid var(--ink); background:var(--ink); }.image-dialog::backdrop { background:var(--backdrop); }.image-dialog img { display:block; width:100%; max-height:88vh; object-fit:contain; }.dialog-close { position:absolute; top:10px; right:10px; border:1px solid var(--paper); background:var(--ink); color:var(--paper); padding:8px 10px; font:12px "DM Mono", monospace; cursor:pointer; }.article-body { max-width:680px; font-size:18px; line-height:1.7; margin-bottom:35px; }.article-text { white-space:pre-wrap; }
.code-block { margin:28px 0; border:1px solid var(--ink); background:var(--ink); }.code-block-head { display:flex; align-items:center; justify-content:space-between; min-height:38px; padding:7px 10px 7px 14px; color:var(--paper); font:11px "DM Mono", monospace; letter-spacing:.7px; text-transform:uppercase; }.copy-code { border:1px solid var(--paper); background:transparent; color:var(--paper); padding:5px 8px; font:11px "DM Mono", monospace; cursor:pointer; }.copy-code:hover { border-color:var(--acid); background:var(--acid); color:var(--ink); }.code-block pre { margin:0; padding:18px; overflow:auto; background:var(--field); color:var(--ink); font:14px/1.55 "DM Mono", monospace; white-space:pre; }.code-block code { font:inherit; }
.flash { margin:16px max(5vw, 24px) 0; padding:11px 14px; font:12px "DM Mono", monospace; border:1px solid var(--ink); }.flash.error { border-color:var(--orange); background:var(--flash-error); }.flash.success { background:var(--acid); }footer { border-top:1px solid var(--ink); padding:23px max(5vw, 24px); display:flex; justify-content:space-between; font:10px "DM Mono", monospace; color:var(--muted); }
.mfa-qr { display:block; width:200px; max-width:100%; margin-top:28px; border:8px solid #fff; image-rendering:pixelated; }.mfa-manual { margin-top:18px; color:var(--muted); font:12px "DM Mono", monospace; }.mfa-manual summary { cursor:pointer; }.mfa-secret { display:block; width:max-content; max-width:100%; overflow-wrap:anywhere; margin-top:10px; padding:14px; border:1px solid var(--ink); background:var(--acid); color:var(--ink); font:14px "DM Mono", monospace; }.account-table { border:1px solid var(--ink); }.account-row { display:grid; grid-template-columns:2fr 1.5fr 1fr 1fr; gap:16px; align-items:center; min-height:64px; padding:12px 16px; border-bottom:1px solid var(--ink); font:13px "DM Mono", monospace; }.account-row:last-child { border-bottom:0; }.account-head { min-height:auto; padding:10px 16px; background:var(--ink); color:var(--paper); font-size:10px; }.account-row small { color:var(--orange); font:10px "DM Mono", monospace; }.account-row .email { display:block; margin-top:5px; color:var(--muted); overflow-wrap:anywhere; } .mfa-qr { display:block; width:200px; max-width:100%; margin-top:28px; border:8px solid #fff; image-rendering:pixelated; }.mfa-manual { margin-top:18px; color:var(--muted); font:12px "DM Mono", monospace; }.mfa-manual summary { cursor:pointer; }.mfa-secret { display:block; width:max-content; max-width:100%; overflow-wrap:anywhere; margin-top:10px; padding:14px; border:1px solid var(--ink); background:var(--acid); color:var(--ink); font:14px "DM Mono", monospace; }.account-table { border:1px solid var(--ink); }.account-row { display:grid; grid-template-columns:2fr 1.5fr 1fr 1fr; gap:16px; align-items:center; min-height:64px; padding:12px 16px; border-bottom:1px solid var(--ink); font:13px "DM Mono", monospace; }.account-row:last-child { border-bottom:0; }.account-head { min-height:auto; padding:10px 16px; background:var(--ink); color:var(--paper); font-size:10px; }.account-row small { color:var(--orange); font:10px "DM Mono", monospace; }.account-row .email { display:block; margin-top:5px; color:var(--muted); overflow-wrap:anywhere; }
@media (max-width:720px) { .site-header { align-items:flex-start; }.site-header nav { justify-content:flex-end; flex-wrap:wrap; gap:10px 15px; }.hero { min-height:560px; }.scope { width:220px; height:220px; right:-45px; }.post-grid, .auth-layout, .two-col { grid-template-columns:1fr; }.auth-layout { gap:40px; padding:60px 24px; }footer { flex-wrap:wrap; gap:8px 16px; }.user-chip { display:none; }.account-row { grid-template-columns:1fr 1fr; }.account-head { display:none; } } .account-actions { display:flex; flex-wrap:wrap; align-items:center; gap:6px; }.account-actions form { margin:0; }.account-actions strong { color:var(--orange); font:10px "DM Mono", monospace; }.delete-account { border-color:var(--orange); background:var(--flash-error); color:var(--ink); }.delete-account:hover { background:var(--orange); }
@media (max-width:720px) { .site-header { align-items:flex-start; }.site-header nav { justify-content:flex-end; flex-wrap:wrap; gap:10px 15px; }.hero { min-height:560px; }.scope { width:220px; height:220px; right:-45px; }.post-grid, .auth-layout, .two-col, .step-image-row { grid-template-columns:1fr; }.auth-layout { gap:40px; padding:60px 24px; }footer { flex-wrap:wrap; gap:8px 16px; }.user-chip { display:none; }.account-row { grid-template-columns:1fr 1fr; }.account-head { display:none; } }
+3 -3
View File
@@ -4,13 +4,13 @@
<section class="editor-wrap"> <section class="editor-wrap">
<div class="section-head"><span>ACCOUNT QUEUE</span><span>{{ users|length }} REGISTERED</span></div> <div class="section-head"><span>ACCOUNT QUEUE</span><span>{{ users|length }} REGISTERED</span></div>
<div class="account-table" role="table"> <div class="account-table" role="table">
<div class="account-row account-head" role="row"><span>HANDLE / EMAIL</span><span>REGISTERED</span><span>MFA</span><span>STATUS</span></div> <div class="account-row account-head" role="row"><span>HANDLE / EMAIL</span><span>REGISTERED</span><span>MFA</span><span>STATUS / ACTIONS</span></div>
{% for user in users %} {% for user in users %}
<div class="account-row" role="row"> <div class="account-row" role="row">
<strong>{{ user.username }}{% if user.role == 'admin' %} <small>ADMIN</small>{% endif %}<small class="email">{{ user.email or 'NO EMAIL' }}</small></strong> <strong>{{ user.real_name or user.username }}{% if user.role == 'admin' %} <small>ADMIN</small>{% endif %}<small class="email">@{{ user.username }} / {{ user.email or 'NO EMAIL' }}{% if user.email %} / {{ 'VERIFIED' if user.email_verified else 'UNVERIFIED' }}{% endif %}</small></strong>
<span>{{ user.created_at[:10] }}</span> <span>{{ user.created_at[:10] }}</span>
<span>{{ 'ENABLED' if user.mfa_enabled else 'PENDING' }}</span> <span>{{ 'ENABLED' if user.mfa_enabled else 'PENDING' }}</span>
<span>{% if user.is_approved and user.role == 'member' %}<form method="post" action="{{ url_for('admin_password_reset', user_id=user.id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="button compact" type="submit">Reset password</button></form>{% elif user.is_approved %}APPROVED{% else %}<form method="post" action="{{ url_for('approve_user', user_id=user.id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="button compact" type="submit">Approve</button></form>{% endif %}</span> <span class="account-actions">{% if user.role == 'member' %}{% if not user.is_approved %}<form method="post" action="{{ url_for('approve_user', user_id=user.id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="button compact" type="submit">Approve</button></form>{% endif %}{% if user.is_frozen %}<strong>FROZEN</strong><form method="post" action="{{ url_for('unfreeze_user', user_id=user.id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="button compact" type="submit">Unfreeze</button></form>{% else %}<form method="post" action="{{ url_for('freeze_user', user_id=user.id) }}" onsubmit="return confirm('Freeze this account? The member will be signed out on their next request.');"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="button compact" type="submit">Freeze</button></form>{% endif %}{% if user.is_approved %}<form method="post" action="{{ url_for('admin_password_reset', user_id=user.id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="button compact" type="submit">Reset password</button></form>{% endif %}<form method="post" action="{{ url_for('delete_user', user_id=user.id) }}" onsubmit="return confirm('Permanently delete this account, its posts, and all uploaded images? This cannot be undone.');"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="button compact delete-account" type="submit">Delete</button></form>{% else %}APPROVED{% endif %}</span>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
+4 -3
View File
@@ -2,14 +2,15 @@
{% block title %}{{ 'Create account' if mode == 'register' else 'Sign in' }} | Eternity Project{% endblock %} {% block title %}{{ 'Create account' if mode == 'register' else 'Sign in' }} | Eternity Project{% endblock %}
{% block content %} {% block content %}
<section class="auth-layout"> <section class="auth-layout">
<div class="auth-copy"><span class="signal-label">MEMBER ACCESS</span><h1>{{ 'Join the circuit.' if mode == 'register' else 'Resume the signal.' }}</h1><p>Accounts let you publish and maintain your own engineering notes.</p></div> <div class="auth-copy"><span class="signal-label">MEMBER ACCESS</span><h1>{% if mode == 'register' %}Join the circuit.{% elif mode == 'resend' %}Verify the signal.{% else %}Resume the signal.{% endif %}</h1><p>{% if mode == 'resend' %}Enter your account credentials to receive a fresh verification link.{% else %}Accounts let you publish and maintain your own engineering notes.{% endif %}</p></div>
<form class="auth-form" method="post"> <form class="auth-form" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label>Handle<input name="username" autocomplete="username" required pattern="[a-z0-9_-]{3,32}" placeholder="your-handle"></label> <label>Handle<input name="username" autocomplete="username" required pattern="[a-z0-9_-]{3,32}" placeholder="your-handle"></label>
{% if mode == 'register' %}<label>Real name<input name="real_name" autocomplete="name" required minlength="2" maxlength="100" placeholder="Your name"></label>{% endif %}
{% if mode == 'register' %}<label>Email<input name="email" type="email" autocomplete="email" required placeholder="you@example.com"></label>{% endif %} {% if mode == 'register' %}<label>Email<input name="email" type="email" autocomplete="email" required placeholder="you@example.com"></label>{% endif %}
<label>Password<input name="password" type="password" autocomplete="{{ 'new-password' if mode == 'register' else 'current-password' }}" required {% if mode == 'register' %}minlength="10" placeholder="10+ characters"{% else %}placeholder="Your password"{% endif %}></label> <label>Password<input name="password" type="password" autocomplete="{{ 'new-password' if mode == 'register' else 'current-password' }}" required {% if mode == 'register' %}minlength="10" placeholder="10+ characters"{% else %}placeholder="Your password"{% endif %}></label>
<button class="button" type="submit">{{ 'Create account' if mode == 'register' else 'Sign in' }} <span aria-hidden="true">→</span></button> <button class="button" type="submit">{% if mode == 'register' %}Create account{% elif mode == 'resend' %}Resend verification{% else %}Sign in{% endif %} <span aria-hidden="true">→</span></button>
<p class="form-switch">{% if mode == 'register' %}Already publishing? <a href="{{ url_for('login') }}">Sign in</a>{% else %}New to the project? <a href="{{ url_for('register') }}">Create an account</a><br><a href="{{ url_for('forgot_password') }}">Forgot your password?</a>{% endif %}</p> <p class="form-switch">{% if mode == 'register' %}Already publishing? <a href="{{ url_for('login') }}">Sign in</a>{% elif mode == 'resend' %}<a href="{{ url_for('login') }}">Back to sign in</a>{% else %}New to the project? <a href="{{ url_for('register') }}">Create an account</a><br><a href="{{ url_for('forgot_password') }}">Forgot your password?</a><br><a href="{{ url_for('resend_verification_email') }}">Resend verification email</a>{% endif %}</p>
</form> </form>
</section> </section>
{% endblock %} {% endblock %}
+18 -1
View File
@@ -9,6 +9,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/site.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/site.css') }}">
<script>(function(){var t=localStorage.getItem('ep-theme');if(t==='dark'){document.documentElement.setAttribute('data-theme','dark');}})();</script>
</head> </head>
<body> <body>
<header class="site-header"> <header class="site-header">
@@ -21,13 +22,16 @@
{% if current_user %} {% if current_user %}
<a href="{{ url_for('write') }}">Write</a> <a href="{{ url_for('write') }}">Write</a>
{% if current_user.role == 'admin' %}<a href="{{ url_for('admin_users') }}">Accounts</a>{% endif %} {% if current_user.role == 'admin' %}<a href="{{ url_for('admin_users') }}">Accounts</a>{% endif %}
<a href="{{ url_for('change_profile') }}">Profile</a>
<a href="{{ url_for('change_email') }}">Email</a>
<a href="{{ url_for('change_password') }}">Password</a> <a href="{{ url_for('change_password') }}">Password</a>
<span class="user-chip">{{ current_user.username }}</span> <span class="user-chip">{{ current_user.real_name or current_user.username }}</span>
<form action="{{ url_for('logout') }}" method="post"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="text-button" type="submit">Log out</button></form> <form action="{{ url_for('logout') }}" method="post"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="text-button" type="submit">Log out</button></form>
{% else %} {% else %}
<a href="{{ url_for('login') }}">Sign in</a> <a href="{{ url_for('login') }}">Sign in</a>
<a class="button compact" href="{{ url_for('register') }}">Create account</a> <a class="button compact" href="{{ url_for('register') }}">Create account</a>
{% endif %} {% endif %}
<button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle dark theme">WASTELAND</button>
</nav> </nav>
</header> </header>
@@ -45,5 +49,18 @@
<span>CODE / CIRCUITS / CONTINUITY</span> <span>CODE / CIRCUITS / CONTINUITY</span>
<span>HELSINKI, FI</span> <span>HELSINKI, FI</span>
</footer> </footer>
<script>
(function(){
var root=document.documentElement,btn=document.getElementById('theme-toggle');
function label(){btn.textContent=root.getAttribute('data-theme')==='dark'?'DAYLIGHT':'WASTELAND';}
label();
btn.addEventListener('click', function(){
var dark=root.getAttribute('data-theme')==='dark';
if (dark) { root.removeAttribute('data-theme'); localStorage.setItem('ep-theme','light'); }
else { root.setAttribute('data-theme','dark'); localStorage.setItem('ep-theme','dark'); }
label();
});
})();
</script>
</body> </body>
</html> </html>
+4 -1
View File
@@ -6,9 +6,12 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label>Title<input name="title" required value="{{ post.title if post else '' }}" placeholder="A clear, useful heading"></label> <label>Title<input name="title" required value="{{ post.title if post else '' }}" placeholder="A clear, useful heading"></label>
<div class="two-col"><label>Category<input name="category" value="{{ post.category if post else 'Engineering' }}" placeholder="Engineering"></label><label>Summary<input name="excerpt" required value="{{ post.excerpt if post else '' }}" placeholder="The one-paragraph signal"></label></div> <div class="two-col"><label>Category<input name="category" value="{{ post.category if post else 'Engineering' }}" placeholder="Engineering"></label><label>Summary<input name="excerpt" required value="{{ post.excerpt if post else '' }}" placeholder="The one-paragraph signal"></label></div>
<label>Article<textarea name="body" required rows="16" placeholder="Write in plain text. Paragraph breaks are preserved.">{{ post.body if post else '' }}</textarea></label> <label>Article<textarea name="body" required rows="16" placeholder="Write in plain text. Paragraph breaks are preserved.">{{ post.body if post else '' }}</textarea><small>Wrap code in triple backticks. Add a language after the opening backticks, for example <code>```python</code>.</small></label>
<label>Article image<input name="image" type="file" accept="image/jpeg,image/png,image/webp">{% if post and post.image_filename %}<small>Leave empty to retain the current image.</small>{% endif %}</label> <label>Article image<input name="image" type="file" accept="image/jpeg,image/png,image/webp">{% if post and post.image_filename %}<small>Leave empty to retain the current image.</small>{% endif %}</label>
<label class="post-visibility"><input name="is_hidden" type="checkbox" value="1" {% if post and post.is_hidden %}checked{% endif %}> Members only <small>Hide this post from visitors who are not signed in.</small></label>
<fieldset class="step-images"><legend>Step images</legend><p>Upload images for individual repair steps, then place <code>[[image:1]]</code>, <code>[[image:2]]</code>, and so on in the article text.</p><div id="step-image-list"><div class="step-image-row"><label>Image<input name="images" type="file" accept="image/jpeg,image/png,image/webp"></label><label>Caption<input name="image_captions" type="text" placeholder="Optional caption"></label></div></div><button class="add-step-image" type="button">Add another step image</button></fieldset>
<button class="button" type="submit">Publish transmission <span aria-hidden="true">→</span></button> <button class="button" type="submit">Publish transmission <span aria-hidden="true">→</span></button>
</form> </form>
</section> </section>
<script>document.querySelector('.add-step-image').addEventListener('click',()=>{const row=document.querySelector('.step-image-row').cloneNode(true);row.querySelectorAll('input').forEach(input=>input.value='');document.querySelector('#step-image-list').append(row)});</script>
{% endblock %} {% endblock %}
+14
View File
@@ -0,0 +1,14 @@
{% extends 'base.html' %}
{% block title %}Email address | Eternity Project{% endblock %}
{% block content %}
<section class="auth-layout">
<div class="auth-copy"><span class="signal-label">ACCOUNT DETAILS</span><h1>Update your<br>address.</h1><p>Use an email address you control. Confirm your password, then verify the link sent to the new address.</p></div>
<form class="auth-form" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label>Current email<input type="email" value="{{ email or '' }}" readonly></label>
<label>New email<input name="email" type="email" autocomplete="email" required placeholder="you@example.com"></label>
<label>Current password<input name="current_password" type="password" autocomplete="current-password" required></label>
<button class="button" type="submit">Send verification <span aria-hidden="true">→</span></button>
</form>
</section>
{% endblock %}
+12
View File
@@ -0,0 +1,12 @@
{% extends 'base.html' %}
{% block title %}Verify email | Eternity Project{% endblock %}
{% block content %}
<section class="auth-layout">
<div class="auth-copy"><span class="signal-label">EMAIL VERIFICATION</span><h1>Confirm the<br>signal.</h1><p>{% if token_type == 'registration' %}Confirm {{ email }} to activate your account after administrator approval.{% else %}Confirm {{ email }} to replace the email address connected to your account.{% endif %}</p></div>
<form class="auth-form" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label>Email<input type="email" value="{{ email }}" readonly></label>
<button class="button" type="submit">Confirm email <span aria-hidden="true">→</span></button>
</form>
</section>
{% endblock %}
+2 -2
View File
@@ -13,10 +13,10 @@
<div class="post-grid"> <div class="post-grid">
{% for post in posts %} {% for post in posts %}
<article class="post-card"> <article class="post-card">
<div class="post-meta"><span>{{ post.category }}</span><time datetime="{{ post.created_at }}">{{ post.created_at[:10] }}</time></div> <div class="post-meta"><span>{{ post.category }}{% if post.is_hidden %} / MEMBERS{% endif %}</span><time datetime="{{ post.created_at }}">{{ post.created_at[:10] }}</time></div>
<h2><a href="{{ url_for('post', slug=post.slug) }}">{{ post.title }}</a></h2> <h2><a href="{{ url_for('post', slug=post.slug) }}">{{ post.title }}</a></h2>
<p>{{ post.excerpt }}</p> <p>{{ post.excerpt }}</p>
<div class="post-foot"><span>BY {{ post.username }}</span><a href="{{ url_for('post', slug=post.slug) }}" aria-label="Read {{ post.title }}">→</a></div> <div class="post-foot"><span>BY {{ post.real_name or post.username }}</span><a href="{{ url_for('post', slug=post.slug) }}" aria-label="Read {{ post.title }}">→</a></div>
</article> </article>
{% endfor %} {% endfor %}
</div> </div>
+3 -2
View File
@@ -1,6 +1,7 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block title %}{{ post.title }} | Eternity Project{% endblock %} {% block title %}{{ post.title }} | Eternity Project{% endblock %}
{% block content %} {% block content %}
<article class="article"><div class="post-meta"><span>{{ post.category }}</span><time datetime="{{ post.created_at }}">{{ post.created_at[:10] }}</time></div><h1>{{ post.title }}</h1><p class="article-lead">{{ post.excerpt }}</p><div class="byline">WRITTEN BY {{ post.username }}{% if post.updated_at != post.created_at %} / UPDATED {{ post.updated_at[:10] }}{% endif %}</div>{% if post.image_filename %}<button class="article-image-button" type="button" aria-label="Open full-size article image"><img class="article-image" src="{{ url_for('uploaded_image', filename=post.image_filename) }}" alt="Image for {{ post.title }}"></button><dialog class="image-dialog"><button class="dialog-close" type="button" aria-label="Close full-size image">Close</button><img src="{{ url_for('uploaded_image', filename=post.image_filename) }}" alt="Full-size image for {{ post.title }}"></dialog>{% endif %}<div class="article-body">{{ post.body }}</div>{% if current_user and current_user.id == post.author_id %}<a class="button compact" href="{{ url_for('edit_post', slug=post.slug) }}">Edit post</a>{% endif %}</article> <article class="article"><div class="post-meta"><span>{{ post.category }}{% if post.is_hidden %} / MEMBERS{% endif %}</span><time datetime="{{ post.created_at }}">{{ post.created_at[:10] }}</time></div><h1>{{ post.title }}</h1><p class="article-lead">{{ post.excerpt }}</p><div class="byline">WRITTEN BY {{ post.real_name or post.username }}{% if post.updated_at != post.created_at %} / UPDATED {{ post.updated_at[:10] }}{% endif %}</div>{% if post.image_filename %}<button class="article-image-button" type="button" data-image="{{ url_for('uploaded_image', filename=post.image_filename) }}" data-alt="Image for {{ post.title }}"><img class="article-image" src="{{ url_for('uploaded_image', filename=post.image_filename) }}" alt="Image for {{ post.title }}"></button>{% endif %}<div class="article-body">{% for kind, content in blocks %}{% if kind == 'text' %}<div class="article-text">{{ content }}</div>{% elif kind == 'code' %}<section class="code-block"><div class="code-block-head"><span>{{ content.language }}</span><button class="copy-code" type="button">Copy</button></div><pre><code class="language-{{ content.language }}">{{ content.content }}</code></pre></section>{% else %}<figure class="inline-image"><button class="article-image-button" type="button" data-image="{{ url_for('uploaded_image', filename=content.filename) }}" data-alt="{{ content.caption or 'Article step image' }}"><img class="article-image" src="{{ url_for('uploaded_image', filename=content.filename) }}" alt="{{ content.caption or 'Article step image' }}"></button>{% if content.caption %}<figcaption>{{ content.caption }}</figcaption>{% endif %}</figure>{% endif %}{% endfor %}</div>{% if current_user and current_user.id == post.author_id %}<a class="button compact" href="{{ url_for('edit_post', slug=post.slug) }}">Edit post</a>{% endif %}</article>
{% if post.image_filename %}<script>const imageButton=document.querySelector('.article-image-button'),imageDialog=document.querySelector('.image-dialog');imageButton.addEventListener('click',()=>imageDialog.showModal());imageDialog.querySelector('.dialog-close').addEventListener('click',()=>imageDialog.close());imageDialog.addEventListener('click',event=>{if(event.target===imageDialog)imageDialog.close()});</script>{% endif %} <dialog class="image-dialog"><button class="dialog-close" type="button" aria-label="Close full-size image">Close</button><img alt=""></dialog>
<script>const imageDialog=document.querySelector('.image-dialog'),dialogImage=imageDialog.querySelector('img');document.querySelectorAll('.article-image-button').forEach(button=>button.addEventListener('click',()=>{dialogImage.src=button.dataset.image;dialogImage.alt=button.dataset.alt;imageDialog.showModal()}));imageDialog.querySelector('.dialog-close').addEventListener('click',()=>imageDialog.close());imageDialog.addEventListener('click',event=>{if(event.target===imageDialog)imageDialog.close()});document.querySelectorAll('.copy-code').forEach(button=>button.addEventListener('click',async()=>{await navigator.clipboard.writeText(button.closest('.code-block').querySelector('code').textContent);button.textContent='Copied';setTimeout(()=>button.textContent='Copy',1500)}));</script>
{% endblock %} {% endblock %}
+12
View File
@@ -0,0 +1,12 @@
{% extends 'base.html' %}
{% block title %}Profile | Eternity Project{% endblock %}
{% block content %}
<section class="auth-layout">
<div class="auth-copy"><span class="signal-label">PROFILE</span><h1>Set your<br>byline.</h1><p>Your real name appears as the published-by name on your posts.</p></div>
<form class="auth-form" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label>Real name<input name="real_name" value="{{ real_name }}" autocomplete="name" required minlength="2" maxlength="100" placeholder="Your name"></label>
<button class="button" type="submit">Save profile <span aria-hidden="true">→</span></button>
</form>
</section>
{% endblock %}
+103
View File
@@ -0,0 +1,103 @@
import tempfile
import unittest
from pathlib import Path
import app as application
from werkzeug.security import generate_password_hash
class AccountLifecycleTestCase(unittest.TestCase):
def setUp(self):
self.temp_directory = tempfile.TemporaryDirectory()
self.database_path = Path(self.temp_directory.name) / "test.db"
self.upload_directory = Path(self.temp_directory.name) / "uploads"
self.original_config = application.app.config.copy()
application.app.config.update(
DATABASE=self.database_path,
UPLOAD_FOLDER=self.upload_directory,
TESTING=True,
WTF_CSRF_ENABLED=False,
)
with application.app.app_context():
application.init_db()
db = application.get_db()
self.admin_id = db.execute(
"""INSERT INTO users (username, email, email_verified, password_hash, created_at, is_approved, role, mfa_enabled)
VALUES (?, ?, 1, ?, ?, 1, 'admin', 1)""",
("administrator", "admin@example.com", generate_password_hash("admin-password"), "2026-09-01T00:00:00+00:00"),
).lastrowid
self.member_id = db.execute(
"""INSERT INTO users (username, email, email_verified, password_hash, created_at, is_approved, role, mfa_enabled)
VALUES (?, ?, 1, ?, ?, 1, 'member', 1)""",
("member", "member@example.com", generate_password_hash("member-password"), "2026-09-01T00:00:00+00:00"),
).lastrowid
db.commit()
self.client = application.app.test_client()
def tearDown(self):
application.app.config.update(self.original_config)
self.temp_directory.cleanup()
def sign_in_as_admin(self):
with self.client.session_transaction() as session:
session["user_id"] = self.admin_id
session["mfa_verified"] = True
def test_freeze_blocks_login_and_active_session(self):
self.sign_in_as_admin()
response = self.client.post(f"/admin/users/{self.member_id}/freeze")
self.assertEqual(response.status_code, 302)
with application.app.app_context():
self.assertEqual(application.get_db().execute("SELECT is_frozen FROM users WHERE id = ?", (self.member_id,)).fetchone()[0], 1)
response = self.client.post("/login", data={"username": "member", "password": "member-password"})
self.assertIn(b"This account has been frozen by an administrator.", response.data)
with self.client.session_transaction() as session:
session["user_id"] = self.member_id
session["mfa_verified"] = True
response = self.client.get("/write")
self.assertEqual(response.status_code, 302)
self.assertIn("/login", response.location)
with self.client.session_transaction() as session:
self.assertNotIn("user_id", session)
def test_delete_member_removes_related_data_and_images(self):
self.upload_directory.mkdir()
primary_image = self.upload_directory / "primary.webp"
inline_image = self.upload_directory / "inline.webp"
primary_image.write_bytes(b"primary")
inline_image.write_bytes(b"inline")
with application.app.app_context():
db = application.get_db()
post_id = db.execute(
"""INSERT INTO posts (title, slug, excerpt, body, category, image_filename, author_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
("Member post", "member-post", "Excerpt", "Body", "Code", primary_image.name, self.member_id, "2026-09-01T00:00:00+00:00", "2026-09-01T00:00:00+00:00"),
).lastrowid
db.execute("INSERT INTO post_images (post_id, filename, caption, position, created_at) VALUES (?, ?, ?, ?, ?)", (post_id, inline_image.name, "", 1, "2026-09-01T00:00:00+00:00"))
db.execute("INSERT INTO password_reset_tokens (user_id, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?)", (self.member_id, "reset-token", "2026-10-01T00:00:00+00:00", "2026-09-01T00:00:00+00:00"))
db.execute("INSERT INTO email_verification_tokens (user_id, email_address, token_hash, token_type, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?)", (self.member_id, "member@example.com", "verification-token", "registration", "2026-10-01T00:00:00+00:00", "2026-09-01T00:00:00+00:00"))
db.commit()
self.sign_in_as_admin()
self.assertEqual(self.client.post(f"/admin/users/{self.member_id}/delete").status_code, 302)
with application.app.app_context():
db = application.get_db()
self.assertIsNone(db.execute("SELECT id FROM users WHERE id = ?", (self.member_id,)).fetchone())
self.assertIsNone(db.execute("SELECT id FROM posts WHERE id = ?", (post_id,)).fetchone())
self.assertEqual(db.execute("SELECT COUNT(*) FROM post_images WHERE post_id = ?", (post_id,)).fetchone()[0], 0)
self.assertEqual(db.execute("SELECT COUNT(*) FROM password_reset_tokens WHERE user_id = ?", (self.member_id,)).fetchone()[0], 0)
self.assertEqual(db.execute("SELECT COUNT(*) FROM email_verification_tokens WHERE user_id = ?", (self.member_id,)).fetchone()[0], 0)
self.assertFalse(primary_image.exists())
self.assertFalse(inline_image.exists())
def test_administrator_accounts_cannot_be_frozen_or_deleted(self):
self.sign_in_as_admin()
self.assertEqual(self.client.post(f"/admin/users/{self.admin_id}/freeze").status_code, 302)
self.assertEqual(self.client.post(f"/admin/users/{self.admin_id}/delete").status_code, 404)
with application.app.app_context():
self.assertEqual(application.get_db().execute("SELECT is_frozen FROM users WHERE id = ?", (self.admin_id,)).fetchone()[0], 0)
if __name__ == "__main__":
unittest.main()
+29
View File
@@ -0,0 +1,29 @@
import unittest
import app as application
class CodeSnippetTestCase(unittest.TestCase):
def test_fenced_code_is_parsed_with_its_language(self):
blocks = application.article_blocks('Intro\n```python\nprint("hi")\n```\nEnd', [])
self.assertEqual(blocks[0], ("text", "Intro\n"))
self.assertEqual(blocks[1], ("code", {"language": "python", "content": 'print("hi")'}))
self.assertEqual(blocks[2], ("text", "\nEnd"))
def test_code_fence_keeps_image_marker_as_literal_code(self):
image = {"filename": "step.webp"}
blocks = application.article_blocks("```\n[[image:1]]\n```\n[[image:1]]", [image])
self.assertEqual(blocks[0], ("code", {"language": "text", "content": "[[image:1]]"}))
self.assertEqual(blocks[1], ("text", "\n"))
self.assertEqual(blocks[2], ("image", image))
def test_unterminated_fence_is_plain_text(self):
body = "```python\nprint('not closed')"
self.assertEqual(application.article_blocks(body, []), [("text", body)])
if __name__ == "__main__":
unittest.main()
+124
View File
@@ -0,0 +1,124 @@
import re
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import app as application
from werkzeug.security import generate_password_hash
class EmailVerificationTestCase(unittest.TestCase):
def setUp(self):
self.temp_directory = tempfile.TemporaryDirectory()
self.database_path = Path(self.temp_directory.name) / "test.db"
self.original_config = application.app.config.copy()
application.app.config.update(
DATABASE=self.database_path,
MAIL_HOST="smtp.example.com",
PUBLIC_URL="https://example.com",
TESTING=True,
WTF_CSRF_ENABLED=False,
)
with application.app.app_context():
application.init_db()
self.client = application.app.test_client()
def tearDown(self):
application.app.config.update(self.original_config)
self.temp_directory.cleanup()
def verification_token_from_message(self, message):
match = re.search(r"/email/verify/([A-Za-z0-9_-]+)", message.get_content())
self.assertIsNotNone(match)
return match.group(1)
def test_registration_requires_email_verification_before_login(self):
with patch("app.smtplib.SMTP") as smtp:
response = self.client.post(
"/register",
data={"username": "newmember", "real_name": "New Member", "email": "new@example.com", "password": "secure-password"},
)
self.assertEqual(response.status_code, 302)
message = smtp.return_value.__enter__.return_value.send_message.call_args.args[0]
token = self.verification_token_from_message(message)
with application.app.app_context():
user = application.get_db().execute("SELECT id, email_verified FROM users WHERE username = ?", ("newmember",)).fetchone()
self.assertEqual(user["email_verified"], 0)
application.get_db().execute("UPDATE users SET is_approved = 1 WHERE id = ?", (user["id"],))
application.get_db().commit()
response = self.client.post("/login", data={"username": "newmember", "password": "secure-password"})
self.assertIn(b"Verify your email address before you can sign in.", response.data)
self.assertEqual(self.client.post(f"/email/verify/{token}").status_code, 302)
with application.app.app_context():
self.assertEqual(application.get_db().execute("SELECT email_verified FROM users WHERE id = ?", (user["id"],)).fetchone()[0], 1)
def test_email_change_keeps_current_address_until_confirmation(self):
with application.app.app_context():
db = application.get_db()
cursor = db.execute(
"""INSERT INTO users (username, email, email_verified, password_hash, created_at, is_approved, role, mfa_enabled)
VALUES (?, ?, 1, ?, ?, 1, 'member', 1)""",
("member", "old@example.com", generate_password_hash("secure-password"), "2026-09-01T00:00:00+00:00"),
)
db.commit()
user_id = cursor.lastrowid
with self.client.session_transaction() as session:
session["user_id"] = user_id
session["mfa_verified"] = True
with patch("app.smtplib.SMTP") as smtp:
response = self.client.post(
"/account/email",
data={"email": "new@example.com", "current_password": "secure-password"},
)
self.assertEqual(response.status_code, 302)
message = smtp.return_value.__enter__.return_value.send_message.call_args.args[0]
token = self.verification_token_from_message(message)
with application.app.app_context():
self.assertEqual(application.get_db().execute("SELECT email FROM users WHERE id = ?", (user_id,)).fetchone()[0], "old@example.com")
self.assertEqual(self.client.post(f"/email/verify/{token}").status_code, 302)
with application.app.app_context():
self.assertEqual(application.get_db().execute("SELECT email FROM users WHERE id = ?", (user_id,)).fetchone()[0], "new@example.com")
def test_profile_name_is_used_for_byline_with_handle_fallback(self):
with application.app.app_context():
db = application.get_db()
named_author = db.execute(
"""INSERT INTO users (username, email, email_verified, password_hash, created_at, is_approved, role, mfa_enabled)
VALUES (?, ?, 1, ?, ?, 1, 'member', 1)""",
("named", "named@example.com", generate_password_hash("secure-password"), "2026-09-01T00:00:00+00:00"),
).lastrowid
legacy_author = db.execute(
"""INSERT INTO users (username, email, email_verified, password_hash, created_at, is_approved, role, mfa_enabled)
VALUES (?, ?, 1, ?, ?, 1, 'member', 1)""",
("legacy", "legacy@example.com", generate_password_hash("secure-password"), "2026-09-01T00:00:00+00:00"),
).lastrowid
db.execute(
"""INSERT INTO posts (title, slug, excerpt, body, category, author_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
("Named post", "named-post", "Named excerpt", "Named body", "Code", named_author, "2026-09-01T00:00:00+00:00", "2026-09-01T00:00:00+00:00"),
)
db.execute(
"""INSERT INTO posts (title, slug, excerpt, body, category, author_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
("Legacy post", "legacy-post", "Legacy excerpt", "Legacy body", "Code", legacy_author, "2026-09-01T00:00:00+00:00", "2026-09-01T00:00:00+00:00"),
)
db.commit()
with self.client.session_transaction() as session:
session["user_id"] = named_author
session["mfa_verified"] = True
response = self.client.post("/account/profile", data={"real_name": "Ada Lovelace"})
self.assertEqual(response.status_code, 302)
self.assertIn(b"BY Ada Lovelace", self.client.get("/").data)
self.assertIn(b"WRITTEN BY Ada Lovelace", self.client.get("/post/named-post").data)
self.assertIn(b"BY legacy", self.client.get("/").data)
if __name__ == "__main__":
unittest.main()
+57
View File
@@ -0,0 +1,57 @@
import tempfile
import unittest
from pathlib import Path
import app as application
from werkzeug.security import generate_password_hash
class PostVisibilityTestCase(unittest.TestCase):
def setUp(self):
self.temp_directory = tempfile.TemporaryDirectory()
self.database_path = Path(self.temp_directory.name) / "test.db"
self.original_config = application.app.config.copy()
application.app.config.update(DATABASE=self.database_path, TESTING=True, WTF_CSRF_ENABLED=False)
with application.app.app_context():
application.init_db()
db = application.get_db()
self.user_id = db.execute(
"""INSERT INTO users (username, real_name, email, email_verified, password_hash, created_at, is_approved, role, mfa_enabled)
VALUES (?, ?, ?, 1, ?, ?, 1, 'member', 1)""",
("member", "Member Name", "member@example.com", generate_password_hash("secure-password"), "2026-09-01T00:00:00+00:00"),
).lastrowid
db.commit()
self.client = application.app.test_client()
def tearDown(self):
application.app.config.update(self.original_config)
self.temp_directory.cleanup()
def test_member_only_post_is_hidden_from_anonymous_visitors(self):
with self.client.session_transaction() as session:
session["user_id"] = self.user_id
session["mfa_verified"] = True
response = self.client.post(
"/write",
data={"title": "Member transmission", "category": "Engineering", "excerpt": "Members only.", "body": "Private body.", "is_hidden": "1"},
)
self.assertEqual(response.status_code, 302)
with application.app.app_context():
self.assertEqual(application.get_db().execute("SELECT is_hidden FROM posts WHERE slug = ?", ("member-transmission",)).fetchone()[0], 1)
self.client.get("/logout")
with self.client.session_transaction() as session:
session.clear()
self.assertNotIn(b"Member transmission", self.client.get("/").data)
self.assertEqual(self.client.get("/post/member-transmission").status_code, 404)
with self.client.session_transaction() as session:
session["user_id"] = self.user_id
session["mfa_verified"] = True
self.assertIn(b"Member transmission", self.client.get("/").data)
self.assertEqual(self.client.get("/post/member-transmission").status_code, 200)
if __name__ == "__main__":
unittest.main()
+77
View File
@@ -0,0 +1,77 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Eternity Theme — Starter Template</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="eternity-theme.css">
<script>(function(){if(localStorage.getItem('ep-theme')==='dark'){document.documentElement.setAttribute('data-theme','dark');}})();</script>
</head>
<body>
<header class="site-header">
<strong style="font-family:'DM Mono',monospace;letter-spacing:1px;">YOUR BRAND</strong>
<nav>
<a href="#">Home</a>
<a href="#">About</a>
<a class="button compact" href="#">Get started</a>
<button type="button" class="theme-toggle" id="theme-toggle">WASTELAND</button>
</nav>
</header>
<main>
<section class="hero">
<span class="signal-label">/// STARTER TEMPLATE</span>
<h1>Reusable <em>post-apocalyptic</em> theme.</h1>
<p>Drop <code>eternity-theme.css</code> into any site, wire up the toggle script, and switch between the daylight and wasteland palettes with a single <code>data-theme</code> attribute.</p>
<button class="button">Primary action</button>
</section>
<section style="padding:40px max(5vw,24px);">
<div class="card-grid">
<div class="card"><h2>Card one</h2><p>Short description of a feature or article.</p></div>
<div class="card"><h2>Card two</h2><p>Short description of a feature or article.</p></div>
<div class="card"><h2>Card three</h2><p>Short description of a feature or article.</p></div>
</div>
</section>
<section style="padding:0 max(5vw,24px) 40px;max-width:600px;">
<form class="panel">
<label>Name<input type="text" placeholder="Jane Doe"></label>
<label>Message<textarea rows="4" placeholder="Say something..."></textarea></label>
<button class="button" type="submit">Send</button>
</form>
<div class="flash success" style="margin-top:16px;">This is a success message.</div>
<div class="flash error">This is an error message.</div>
</section>
<section style="padding:0 max(5vw,24px) 60px;max-width:700px;">
<div class="code-block">
<div class="code-block-head"><span>example.js</span><span>copy</span></div>
<pre><code>console.log("themed code block");</code></pre>
</div>
</section>
</main>
<footer>
<span>YOUR BRAND</span>
<span>MADE WITH ETERNITY THEME</span>
</footer>
<script>
(function(){
var root=document.documentElement,btn=document.getElementById('theme-toggle');
function label(){btn.textContent=root.getAttribute('data-theme')==='dark'?'DAYLIGHT':'WASTELAND';}
label();
btn.addEventListener('click', function(){
var dark=root.getAttribute('data-theme')==='dark';
if (dark) { root.removeAttribute('data-theme'); localStorage.setItem('ep-theme','light'); }
else { root.setAttribute('data-theme','dark'); localStorage.setItem('ep-theme','dark'); }
label();
});
})();
</script>
</body>
</html>
+107
View File
@@ -0,0 +1,107 @@
/* Eternity Project theme — portable core styles.
Usage: <html data-theme="dark"> to activate the wasteland variant,
or toggle the attribute at runtime and persist the choice yourself. */
:root {
--ink:#11212b; --paper:#f1f1e9; --acid:#c7ef4b; --orange:#ff6b35;
--line:#b9c5bc; --muted:#59706b;
--panel:rgba(241,241,233,.8); --field:#fbfbf6; --backdrop:rgba(17,33,43,.85);
--flash-error:#ffe3d9; --grid:17,33,43; --grid-a1:.05; --grid-a2:.04;
}
[data-theme="dark"] {
--ink:#e7e2d3; --paper:#161210; --acid:#8fae3d; --orange:#e4632c;
--line:#4a3f33; --muted:#a89a86;
--panel:rgba(22,18,16,.82); --field:#211b17; --backdrop:rgba(8,6,5,.9);
--flash-error:#3a1c14; --grid:228,98,44; --grid-a1:.07; --grid-a2:.05;
}
* { box-sizing:border-box; }
html { scroll-behavior:smooth; }
body {
margin:0; background:var(--paper); color:var(--ink);
font-family:"Space Grotesk", sans-serif; transition:background .2s, color .2s;
}
body::before {
content:""; position:fixed; inset:0; pointer-events:none; opacity:.4;
background-image:
linear-gradient(90deg, transparent 49.5%, rgba(var(--grid),var(--grid-a1)) 50%, transparent 50.5%),
linear-gradient(rgba(var(--grid),var(--grid-a2)) 1px, transparent 1px);
background-size:70px 70px, 7px 7px;
}
[data-theme="dark"] body::after {
content:""; position:fixed; inset:0; pointer-events:none; opacity:.5; mix-blend-mode:overlay;
background-image:
radial-gradient(circle at 12% 20%, rgba(143,174,61,.12), transparent 35%),
radial-gradient(circle at 85% 75%, rgba(228,98,44,.14), transparent 40%);
}
a { color:inherit; text-decoration:none; }
/* header / nav */
.site-header {
min-height:76px; padding:16px max(5vw, 24px); border-bottom:1px solid var(--ink);
display:flex; align-items:center; justify-content:space-between; gap:24px; position:relative; z-index:1;
}
nav { display:flex; align-items:center; gap:22px; font-family:"DM Mono", monospace; font-size:12px; }
nav a:hover { color:var(--orange); }
/* buttons */
.button {
display:inline-flex; align-items:center; justify-content:center; gap:18px;
background:var(--ink); color:var(--paper); padding:13px 17px; border:1px solid var(--ink);
font:500 13px "DM Mono", monospace; cursor:pointer;
}
.button:hover { background:var(--acid); color:var(--ink); }
.button.compact { padding:9px 12px; }
.theme-toggle {
border:1px solid var(--ink); background:transparent; color:inherit; padding:6px 9px;
font:11px "DM Mono", monospace; cursor:pointer; letter-spacing:.5px;
}
.theme-toggle:hover { background:var(--acid); color:var(--paper); }
/* hero */
.hero {
min-height:420px; padding:clamp(70px, 12vw, 150px) max(5vw, 24px) 75px;
border-bottom:1px solid var(--ink); position:relative; overflow:hidden; z-index:1;
}
.signal-label { color:var(--orange); font:11px "DM Mono", monospace; letter-spacing:.7px; }
.hero h1 { max-width:820px; margin:20px 0; font-size:clamp(40px, 9vw, 110px); line-height:.88; font-weight:600; }
.hero h1 em { font-family:Georgia, serif; font-weight:400; }
.hero p { max-width:510px; color:var(--muted); font-size:18px; line-height:1.45; margin:28px 0; }
/* cards / grid */
.card-grid { display:grid; grid-template-columns:repeat(3, 1fr); border-top:1px solid var(--ink); border-left:1px solid var(--ink); }
.card { min-height:260px; padding:21px; border-right:1px solid var(--ink); border-bottom:1px solid var(--ink); display:flex; flex-direction:column; transition:background .2s; }
.card:hover { background:var(--acid); }
.card h2 { font-size:26px; line-height:1; margin:22px 0 12px; }
.card p { line-height:1.45; margin:0; color:var(--muted); }
/* forms */
.panel { padding:27px; border:1px solid var(--ink); background:var(--panel); display:grid; gap:19px; }
label { display:grid; gap:7px; font:11px "DM Mono", monospace; }
input, textarea {
width:100%; resize:vertical; background:var(--field); border:1px solid var(--ink);
border-radius:0; padding:12px; color:var(--ink); font:15px "Space Grotesk", sans-serif;
}
input:focus, textarea:focus { outline:3px solid var(--acid); outline-offset:1px; }
/* code blocks */
.code-block { margin:28px 0; border:1px solid var(--ink); background:var(--ink); }
.code-block-head {
display:flex; align-items:center; justify-content:space-between; min-height:38px;
padding:7px 10px 7px 14px; color:var(--paper); font:11px "DM Mono", monospace;
letter-spacing:.7px; text-transform:uppercase;
}
.code-block pre { margin:0; padding:18px; overflow:auto; background:var(--field); color:var(--ink); font:14px/1.55 "DM Mono", monospace; white-space:pre; }
/* flash / alerts */
.flash { margin:16px 0; padding:11px 14px; font:12px "DM Mono", monospace; border:1px solid var(--ink); }
.flash.error { border-color:var(--orange); background:var(--flash-error); }
.flash.success { background:var(--acid); }
footer { border-top:1px solid var(--ink); padding:23px max(5vw, 24px); display:flex; justify-content:space-between; font:10px "DM Mono", monospace; color:var(--muted); position:relative; z-index:1; }
@media (max-width:720px) {
.card-grid { grid-template-columns:1fr; }
.site-header { flex-wrap:wrap; }
}