first commit

This commit is contained in:
2026-08-29 10:35:34 +03:00
commit 1089c784e9
17 changed files with 543 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.git
.venv
__pycache__
*.pyc
data
.env
+2
View File
@@ -0,0 +1,2 @@
# Generate a long, random value before deploying.
SECRET_KEY=replace-with-a-long-random-secret
+16
View File
@@ -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"]
+22
View File
@@ -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.
+317
View File
@@ -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)
+15
View File
@@ -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:
BIN
View File
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
Flask==3.1.1
gunicorn==23.0.0
pyotp==2.9.0
+18
View File
@@ -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; } }
+18
View File
@@ -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 %}
+13
View File
@@ -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 %}
+48
View File
@@ -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>
+12
View File
@@ -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 %}
+27
View File
@@ -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 %}
+21
View File
@@ -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 %}
+5
View File
@@ -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 %}