first commit
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
.git
|
||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
data
|
||||||
|
.env
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Generate a long, random value before deploying.
|
||||||
|
SECRET_KEY=replace-with-a-long-random-secret
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
DATABASE_PATH=/data/eternity.db
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN useradd --create-home appuser && mkdir -p /data && chown -R appuser:appuser /app /data
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "app:app"]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Eternity Project web server
|
||||||
|
|
||||||
|
A Dockerized publishing service for eternityproject.fi. It provides local accounts, password hashing, authenticated publishing, and owner-only post editing. Content is persisted to SQLite in a named Docker volume.
|
||||||
|
|
||||||
|
## Run it
|
||||||
|
|
||||||
|
1. Copy `.env.example` to `.env` and replace `SECRET_KEY` with a long random string.
|
||||||
|
2. Build and start the service:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:8000`, create the first account, and publish a field note. The first request initializes the database automatically.
|
||||||
|
|
||||||
|
## Accounts and MFA
|
||||||
|
|
||||||
|
The initial administrator account is `admin` with password `admin`, as requested for first-run access. Sign in, enroll MFA, 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.
|
||||||
|
|
||||||
|
## Production notes
|
||||||
|
|
||||||
|
Put this service behind a TLS reverse proxy for `eternityproject.fi` (for example Caddy or Nginx). Set a strong unique `SECRET_KEY`; the Compose file intentionally refuses to start without it. Back up the `eternity_data` Docker volume, which contains accounts and posts.
|
||||||
Binary file not shown.
@@ -0,0 +1,317 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from functools import wraps
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import Flask, abort, flash, g, redirect, render_template, request, session, url_for
|
||||||
|
import pyotp
|
||||||
|
from werkzeug.security import check_password_hash, generate_password_hash
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
DATABASE = Path(os.environ.get("DATABASE_PATH", BASE_DIR / "data" / "eternity.db"))
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config.update(
|
||||||
|
SECRET_KEY=os.environ.get("SECRET_KEY", "change-this-secret-before-production"),
|
||||||
|
DATABASE=DATABASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
if "db" not in g:
|
||||||
|
app.config["DATABASE"].parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
g.db = sqlite3.connect(app.config["DATABASE"])
|
||||||
|
g.db.row_factory = sqlite3.Row
|
||||||
|
return g.db
|
||||||
|
|
||||||
|
|
||||||
|
@app.teardown_appcontext
|
||||||
|
def close_db(_error=None):
|
||||||
|
db = g.pop("db", None)
|
||||||
|
if db is not None:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
db = get_db()
|
||||||
|
db.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
is_approved INTEGER NOT NULL DEFAULT 0,
|
||||||
|
role TEXT NOT NULL DEFAULT 'member',
|
||||||
|
mfa_secret TEXT,
|
||||||
|
mfa_enabled INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS posts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
slug TEXT UNIQUE NOT NULL,
|
||||||
|
excerpt TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
author_id INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (author_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
user_columns = {row["name"] for row in db.execute("PRAGMA table_info(users)")}
|
||||||
|
migrations = {
|
||||||
|
"is_approved": "ALTER TABLE users ADD COLUMN is_approved INTEGER NOT NULL DEFAULT 0",
|
||||||
|
"role": "ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'member'",
|
||||||
|
"mfa_secret": "ALTER TABLE users ADD COLUMN mfa_secret TEXT",
|
||||||
|
"mfa_enabled": "ALTER TABLE users ADD COLUMN mfa_enabled INTEGER NOT NULL DEFAULT 0",
|
||||||
|
}
|
||||||
|
for column, statement in migrations.items():
|
||||||
|
if column not in user_columns:
|
||||||
|
db.execute(statement)
|
||||||
|
|
||||||
|
# Preserve access for accounts created before approvals were introduced.
|
||||||
|
db.execute("UPDATE users SET is_approved = 1 WHERE role = 'member' AND is_approved = 0")
|
||||||
|
admin = db.execute("SELECT id FROM users WHERE username = 'admin'").fetchone()
|
||||||
|
if admin is None:
|
||||||
|
db.execute(
|
||||||
|
"""INSERT INTO users (username, password_hash, created_at, is_approved, role)
|
||||||
|
VALUES (?, ?, ?, 1, 'admin')""",
|
||||||
|
("admin", generate_password_hash("admin"), datetime.now(timezone.utc).isoformat()),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def login_required(view):
|
||||||
|
@wraps(view)
|
||||||
|
def wrapped_view(*args, **kwargs):
|
||||||
|
if "user_id" not in session or not session.get("mfa_verified"):
|
||||||
|
flash("Sign in to continue.", "notice")
|
||||||
|
return redirect(url_for("login", next=request.path))
|
||||||
|
return view(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapped_view
|
||||||
|
|
||||||
|
|
||||||
|
def admin_required(view):
|
||||||
|
@wraps(view)
|
||||||
|
@login_required
|
||||||
|
def wrapped_view(*args, **kwargs):
|
||||||
|
user = get_db().execute("SELECT role FROM users WHERE id = ?", (session["user_id"],)).fetchone()
|
||||||
|
if user is None or user["role"] != "admin":
|
||||||
|
abort(403)
|
||||||
|
return view(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapped_view
|
||||||
|
|
||||||
|
|
||||||
|
def unique_slug(title, post_id=None):
|
||||||
|
base = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or "untitled"
|
||||||
|
slug = base
|
||||||
|
counter = 2
|
||||||
|
db = get_db()
|
||||||
|
while True:
|
||||||
|
row = db.execute("SELECT id FROM posts WHERE slug = ?", (slug,)).fetchone()
|
||||||
|
if row is None or row["id"] == post_id:
|
||||||
|
return slug
|
||||||
|
slug = f"{base}-{counter}"
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
|
||||||
|
def post_from_request(post_id=None):
|
||||||
|
title = request.form.get("title", "").strip()
|
||||||
|
excerpt = request.form.get("excerpt", "").strip()
|
||||||
|
body = request.form.get("body", "").strip()
|
||||||
|
category = request.form.get("category", "Engineering").strip() or "Engineering"
|
||||||
|
if not title or not excerpt or not body:
|
||||||
|
raise ValueError("Title, summary, and article body are required.")
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
return title, unique_slug(title, post_id), excerpt, body, category, now
|
||||||
|
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def inject_current_user():
|
||||||
|
user = None
|
||||||
|
if "user_id" in session:
|
||||||
|
user = get_db().execute("SELECT id, username, role FROM users WHERE id = ?", (session["user_id"],)).fetchone()
|
||||||
|
return {"current_user": user}
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
posts = get_db().execute(
|
||||||
|
"""SELECT posts.*, users.username FROM posts JOIN users ON users.id = posts.author_id
|
||||||
|
ORDER BY posts.created_at DESC"""
|
||||||
|
).fetchall()
|
||||||
|
return render_template("index.html", posts=posts)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/post/<slug>")
|
||||||
|
def post(slug):
|
||||||
|
article = get_db().execute(
|
||||||
|
"""SELECT posts.*, users.username FROM posts JOIN users ON users.id = posts.author_id
|
||||||
|
WHERE posts.slug = ?""",
|
||||||
|
(slug,),
|
||||||
|
).fetchone()
|
||||||
|
if article is None:
|
||||||
|
abort(404)
|
||||||
|
return render_template("post.html", post=article)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/register", methods=("GET", "POST"))
|
||||||
|
def register():
|
||||||
|
if request.method == "POST":
|
||||||
|
username = request.form.get("username", "").strip().lower()
|
||||||
|
password = request.form.get("password", "")
|
||||||
|
if not re.fullmatch(r"[a-z0-9_-]{3,32}", username):
|
||||||
|
flash("Use 3-32 lowercase letters, numbers, hyphens, or underscores.", "error")
|
||||||
|
elif len(password) < 10:
|
||||||
|
flash("Choose a password with at least 10 characters.", "error")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
get_db().execute(
|
||||||
|
"INSERT INTO users (username, password_hash, created_at, is_approved) VALUES (?, ?, ?, 0)",
|
||||||
|
(username, generate_password_hash(password), datetime.now(timezone.utc).isoformat()),
|
||||||
|
)
|
||||||
|
get_db().commit()
|
||||||
|
flash("Registration received. An administrator must approve it before you can sign in.", "success")
|
||||||
|
return redirect(url_for("login"))
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
flash("That handle is already in use.", "error")
|
||||||
|
return render_template("auth.html", mode="register")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/login", methods=("GET", "POST"))
|
||||||
|
def login():
|
||||||
|
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 None or not check_password_hash(user["password_hash"], request.form.get("password", "")):
|
||||||
|
flash("Invalid handle or password.", "error")
|
||||||
|
elif not user["is_approved"]:
|
||||||
|
flash("Your account is awaiting administrator approval.", "notice")
|
||||||
|
else:
|
||||||
|
session.clear()
|
||||||
|
session["mfa_pending_user_id"] = user["id"]
|
||||||
|
session["login_next"] = request.args.get("next") or url_for("index")
|
||||||
|
if user["mfa_enabled"]:
|
||||||
|
return redirect(url_for("mfa_verify"))
|
||||||
|
return redirect(url_for("mfa_setup"))
|
||||||
|
return render_template("auth.html", mode="login")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/logout")
|
||||||
|
def logout():
|
||||||
|
session.clear()
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
|
||||||
|
def pending_mfa_user():
|
||||||
|
user_id = session.get("mfa_pending_user_id")
|
||||||
|
if user_id is None:
|
||||||
|
return None
|
||||||
|
return get_db().execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/mfa/setup", methods=("GET", "POST"))
|
||||||
|
def mfa_setup():
|
||||||
|
user = pending_mfa_user()
|
||||||
|
if user is None or user["mfa_enabled"]:
|
||||||
|
return redirect(url_for("login"))
|
||||||
|
secret = session.setdefault("mfa_setup_secret", pyotp.random_base32())
|
||||||
|
if request.method == "POST":
|
||||||
|
if pyotp.TOTP(secret).verify(request.form.get("code", ""), valid_window=1):
|
||||||
|
get_db().execute("UPDATE users SET mfa_secret = ?, mfa_enabled = 1 WHERE id = ?", (secret, user["id"]))
|
||||||
|
get_db().commit()
|
||||||
|
next_url = session.get("login_next", url_for("index"))
|
||||||
|
session.clear()
|
||||||
|
session["user_id"] = user["id"]
|
||||||
|
session["mfa_verified"] = True
|
||||||
|
return redirect(next_url)
|
||||||
|
flash("That verification code was not accepted. Try the current code.", "error")
|
||||||
|
return render_template("mfa.html", mode="setup", secret=secret, username=user["username"])
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/mfa/verify", methods=("GET", "POST"))
|
||||||
|
def mfa_verify():
|
||||||
|
user = pending_mfa_user()
|
||||||
|
if user is None or not user["mfa_enabled"] or not user["mfa_secret"]:
|
||||||
|
return redirect(url_for("login"))
|
||||||
|
if request.method == "POST":
|
||||||
|
if pyotp.TOTP(user["mfa_secret"]).verify(request.form.get("code", ""), valid_window=1):
|
||||||
|
next_url = session.get("login_next", url_for("index"))
|
||||||
|
session.clear()
|
||||||
|
session["user_id"] = user["id"]
|
||||||
|
session["mfa_verified"] = True
|
||||||
|
return redirect(next_url)
|
||||||
|
flash("That verification code was not accepted. Try the current code.", "error")
|
||||||
|
return render_template("mfa.html", mode="verify", username=user["username"])
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/admin/users")
|
||||||
|
@admin_required
|
||||||
|
def admin_users():
|
||||||
|
users = get_db().execute(
|
||||||
|
"SELECT id, username, created_at, is_approved, mfa_enabled, role FROM users ORDER BY is_approved, created_at DESC"
|
||||||
|
).fetchall()
|
||||||
|
return render_template("admin_users.html", users=users)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/users/<int:user_id>/approve")
|
||||||
|
@admin_required
|
||||||
|
def approve_user(user_id):
|
||||||
|
cursor = get_db().execute("UPDATE users SET is_approved = 1 WHERE id = ? AND role = 'member'", (user_id,))
|
||||||
|
get_db().commit()
|
||||||
|
flash("Account approved." if cursor.rowcount else "No pending member account was changed.", "success")
|
||||||
|
return redirect(url_for("admin_users"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/write", methods=("GET", "POST"))
|
||||||
|
@login_required
|
||||||
|
def write():
|
||||||
|
if request.method == "POST":
|
||||||
|
try:
|
||||||
|
title, slug, excerpt, body, category, now = post_from_request()
|
||||||
|
get_db().execute(
|
||||||
|
"""INSERT INTO posts (title, slug, excerpt, body, category, author_id, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(title, slug, excerpt, body, category, session["user_id"], now, now),
|
||||||
|
)
|
||||||
|
get_db().commit()
|
||||||
|
return redirect(url_for("post", slug=slug))
|
||||||
|
except ValueError as error:
|
||||||
|
flash(str(error), "error")
|
||||||
|
return render_template("editor.html", post=None)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/post/<slug>/edit", methods=("GET", "POST"))
|
||||||
|
@login_required
|
||||||
|
def edit_post(slug):
|
||||||
|
article = get_db().execute("SELECT * FROM posts WHERE slug = ?", (slug,)).fetchone()
|
||||||
|
if article is None:
|
||||||
|
abort(404)
|
||||||
|
if article["author_id"] != session["user_id"]:
|
||||||
|
abort(403)
|
||||||
|
if request.method == "POST":
|
||||||
|
try:
|
||||||
|
title, new_slug, excerpt, body, category, now = post_from_request(article["id"])
|
||||||
|
get_db().execute(
|
||||||
|
"""UPDATE posts SET title=?, slug=?, excerpt=?, body=?, category=?, updated_at=? WHERE id=?""",
|
||||||
|
(title, new_slug, excerpt, body, category, now, article["id"]),
|
||||||
|
)
|
||||||
|
get_db().commit()
|
||||||
|
return redirect(url_for("post", slug=new_slug))
|
||||||
|
except ValueError as error:
|
||||||
|
flash(str(error), "error")
|
||||||
|
return render_template("editor.html", post=article)
|
||||||
|
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=8000)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
services:
|
||||||
|
eternityproject:
|
||||||
|
build: .
|
||||||
|
container_name: eternityproject-web
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env before deployment}
|
||||||
|
DATABASE_PATH: /data/eternity.db
|
||||||
|
volumes:
|
||||||
|
- eternity_data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
eternity_data:
|
||||||
Binary file not shown.
@@ -0,0 +1,3 @@
|
|||||||
|
Flask==3.1.1
|
||||||
|
gunicorn==23.0.0
|
||||||
|
pyotp==2.9.0
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
:root { --ink:#11212b; --paper:#f1f1e9; --acid:#c7ef4b; --orange:#ff6b35; --line:#b9c5bc; --muted:#59706b; }
|
||||||
|
* { box-sizing:border-box; }
|
||||||
|
html { scroll-behavior:smooth; }
|
||||||
|
body { margin:0; background:var(--paper); color:var(--ink); font-family:"Space Grotesk", sans-serif; }
|
||||||
|
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; }
|
||||||
|
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; }
|
||||||
|
.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-mark { width:31px; height:31px; border:2px solid var(--ink); border-radius:50%; display:grid; place-items:center; position:relative; }.brand-mark i { position:absolute; width:4px; height:4px; border-radius:50%; background:var(--orange); }.brand-mark i:nth-child(1){ transform:translate(-7px,-7px) }.brand-mark i:nth-child(2){ transform:translate(7px,7px) }.brand-mark i:nth-child(3){ background:var(--acid); }
|
||||||
|
nav { display:flex; align-items:center; gap:22px; font-family:"DM Mono", monospace; font-size:12px; } nav a:not(.button):hover, .text-button:hover { color:var(--orange); }.user-chip { background:var(--acid); padding:6px 8px; }.text-button { border:0; padding:0; background:none; color:inherit; font:inherit; cursor:pointer; }
|
||||||
|
.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) }
|
||||||
|
.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; }
|
||||||
|
.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-body { max-width:680px; white-space:pre-wrap; font-size:18px; line-height:1.7; margin-bottom:35px; }
|
||||||
|
.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); }
|
||||||
|
.mfa-secret { display:block; width:max-content; max-width:100%; overflow-wrap:anywhere; margin-top:28px; padding:14px; border:1px solid var(--ink); background:var(--acid); 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; }
|
||||||
|
@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; } }
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}Account administration | Eternity Project{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="editor-wrap">
|
||||||
|
<div class="section-head"><span>ACCOUNT QUEUE</span><span>{{ users|length }} REGISTERED</span></div>
|
||||||
|
<div class="account-table" role="table">
|
||||||
|
<div class="account-row account-head" role="row"><span>HANDLE</span><span>REGISTERED</span><span>MFA</span><span>STATUS</span></div>
|
||||||
|
{% for user in users %}
|
||||||
|
<div class="account-row" role="row">
|
||||||
|
<strong>{{ user.username }}{% if user.role == 'admin' %} <small>ADMIN</small>{% endif %}</strong>
|
||||||
|
<span>{{ user.created_at[:10] }}</span>
|
||||||
|
<span>{{ 'ENABLED' if user.mfa_enabled else 'PENDING' }}</span>
|
||||||
|
<span>{% if user.is_approved %}APPROVED{% elif user.role == 'member' %}<form method="post" action="{{ url_for('approve_user', user_id=user.id) }}"><button class="button compact" type="submit">Approve</button></form>{% else %}ADMIN{% endif %}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}{{ 'Create account' if mode == 'register' else 'Sign in' }} | Eternity Project{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<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>
|
||||||
|
<form class="auth-form" method="post">
|
||||||
|
<label>Handle<input name="username" autocomplete="username" required pattern="[a-z0-9_-]{3,32}" placeholder="your-handle"></label>
|
||||||
|
<label>Password<input name="password" type="password" autocomplete="{{ 'new-password' if mode == 'register' else 'current-password' }}" required minlength="10" placeholder="10+ characters"></label>
|
||||||
|
<button class="button" type="submit">{{ 'Create account' if mode == 'register' else 'Sign in' }} <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>{% endif %}</p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="description" content="Eternity Project: field notes on code, circuits, and systems.">
|
||||||
|
<title>{% block title %}Eternity Project{% endblock %}</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="{{ url_for('static', filename='css/site.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="site-header">
|
||||||
|
<a class="brand" href="{{ url_for('index') }}" aria-label="Eternity Project home">
|
||||||
|
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
|
||||||
|
<span><strong>ETERNITY</strong><small>PROJECT / FI</small></span>
|
||||||
|
</a>
|
||||||
|
<nav aria-label="Main navigation">
|
||||||
|
<a href="{{ url_for('index') }}#journal">Journal</a>
|
||||||
|
{% if current_user %}
|
||||||
|
<a href="{{ url_for('write') }}">Write</a>
|
||||||
|
{% if current_user.role == 'admin' %}<a href="{{ url_for('admin_users') }}">Accounts</a>{% endif %}
|
||||||
|
<span class="user-chip">{{ current_user.username }}</span>
|
||||||
|
<form action="{{ url_for('logout') }}" method="post"><button class="text-button" type="submit">Log out</button></form>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('login') }}">Sign in</a>
|
||||||
|
<a class="button compact" href="{{ url_for('register') }}">Create account</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="flash {{ category }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<span>ETERNITY PROJECT</span>
|
||||||
|
<span>CODE / CIRCUITS / CONTINUITY</span>
|
||||||
|
<span>HELSINKI, FI</span>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}{{ 'Edit' if post else 'Write' }} | Eternity Project{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="editor-wrap"><div class="section-head"><span>{{ 'EDIT TRANSMISSION' if post else 'NEW TRANSMISSION' }}</span><span>AUTHOR / {{ current_user.username }}</span></div>
|
||||||
|
<form class="editor-form" method="post">
|
||||||
|
<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>
|
||||||
|
<label>Article<textarea name="body" required rows="16" placeholder="Write in plain text. Paragraph breaks are preserved.">{{ post.body if post else '' }}</textarea></label>
|
||||||
|
<button class="button" type="submit">Publish transmission <span aria-hidden="true">→</span></button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="hero">
|
||||||
|
<div class="signal-label">ELECTRONIC FIELD NOTES / 001</div>
|
||||||
|
<h1>Build for the<br><em>long current.</em></h1>
|
||||||
|
<p>Notes from Eternity Project on embedded systems, durable software, and the strange pleasure of making electrons do useful work.</p>
|
||||||
|
<a class="button" href="#journal">Read the journal <span aria-hidden="true">↓</span></a>
|
||||||
|
<div class="scope" aria-hidden="true"><span></span><span></span><span></span><b>∞</b></div>
|
||||||
|
</section>
|
||||||
|
<section class="journal" id="journal">
|
||||||
|
<div class="section-head"><span>THE JOURNAL</span><span>{{ posts|length }} TRANSMISSIONS</span></div>
|
||||||
|
{% if posts %}
|
||||||
|
<div class="post-grid">
|
||||||
|
{% for post in posts %}
|
||||||
|
<article class="post-card">
|
||||||
|
<div class="post-meta"><span>{{ post.category }}</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>
|
||||||
|
<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>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state"><span class="empty-icon">//</span><h2>The signal is quiet.</h2><p>Be the first account to publish a field note.</p>{% if current_user %}<a class="button" href="{{ url_for('write') }}">Write a post</a>{% endif %}</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}Multi-factor verification | Eternity Project{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="auth-layout">
|
||||||
|
<div class="auth-copy">
|
||||||
|
<span class="signal-label">ACCOUNT SECURITY</span>
|
||||||
|
{% if mode == 'setup' %}
|
||||||
|
<h1>Bind your<br>authenticator.</h1>
|
||||||
|
<p>Add this secret to an authenticator app as a time-based one-time password account, then enter its current six-digit code.</p>
|
||||||
|
<code class="mfa-secret">{{ secret }}</code>
|
||||||
|
{% else %}
|
||||||
|
<h1>Confirm the<br>signal.</h1>
|
||||||
|
<p>Open your authenticator application and enter the current code for {{ username }}.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<form class="auth-form" method="post">
|
||||||
|
<label>Authenticator code<input name="code" inputmode="numeric" autocomplete="one-time-code" required pattern="[0-9]{6}" maxlength="6" placeholder="000000"></label>
|
||||||
|
<button class="button" type="submit">Verify account <span aria-hidden="true">→</span></button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}{{ post.title }} | Eternity Project{% endblock %}
|
||||||
|
{% 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><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>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user