Add ability to hide posts from unregistered users.
This commit is contained in:
@@ -27,6 +27,8 @@ 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.
|
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.
|
||||||
|
|
||||||
## 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.
|
||||||
|
|||||||
@@ -15,4 +15,4 @@
|
|||||||
- [x] Allow users to change their email addresses.
|
- [x] Allow users to change their email addresses.
|
||||||
- [x] Add user email verification for registration and email-address changes.
|
- [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 user real names and use them as the published-by name.
|
||||||
- [ ] Add ability to hide posts from unregistered users.
|
- [x] Add ability to hide posts from unregistered users.
|
||||||
@@ -78,6 +78,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)
|
||||||
@@ -131,6 +132,8 @@ def init_db():
|
|||||||
db.execute("UPDATE users SET email_verified = 1 WHERE email IS NOT NULL")
|
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.
|
||||||
@@ -186,10 +189,11 @@ 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(image_file):
|
def save_post_image(image_file):
|
||||||
@@ -342,20 +346,20 @@ def inject_current_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, users.real_name 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, users.real_name 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)
|
||||||
images = get_db().execute("SELECT * FROM post_images WHERE post_id = ? ORDER BY position", (article["id"],)).fetchall()
|
images = get_db().execute("SELECT * FROM post_images WHERE post_id = ? ORDER BY position", (article["id"],)).fetchall()
|
||||||
@@ -668,13 +672,13 @@ 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_file = request.files.get("image")
|
image_file = request.files.get("image")
|
||||||
image_filename = save_post_image(image_file) if image_file and image_file.filename else None
|
image_filename = save_post_image(image_file) if image_file and image_file.filename else None
|
||||||
cursor = get_db().execute(
|
cursor = get_db().execute(
|
||||||
"""INSERT INTO posts (title, slug, excerpt, body, category, image_filename, author_id, created_at, updated_at)
|
"""INSERT INTO posts (title, slug, excerpt, body, category, image_filename, author_id, is_hidden, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(title, slug, excerpt, body, category, image_filename, session["user_id"], now, now),
|
(title, slug, excerpt, body, category, image_filename, session["user_id"], is_hidden, now, now),
|
||||||
)
|
)
|
||||||
save_inline_images(cursor.lastrowid)
|
save_inline_images(cursor.lastrowid)
|
||||||
get_db().commit()
|
get_db().commit()
|
||||||
@@ -694,12 +698,12 @@ 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_file = request.files.get("image")
|
image_file = request.files.get("image")
|
||||||
image_filename = save_post_image(image_file) if image_file and image_file.filename else article["image_filename"]
|
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"])
|
save_inline_images(article["id"])
|
||||||
get_db().commit()
|
get_db().commit()
|
||||||
|
|||||||
Binary file not shown.
@@ -13,6 +13,7 @@ nav { display:flex; align-items:center; gap:22px; font-family:"DM Mono", monospa
|
|||||||
.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: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; }
|
||||||
.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); }
|
.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); }
|
||||||
|
.post-visibility { grid-template-columns:auto 1fr; align-items:center; column-gap:9px; padding:12px; border:1px solid var(--ink); background:rgba(241,241,233,.8); 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: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; font-size:18px; line-height:1.7; margin-bottom:35px; }.article-text { white-space:pre-wrap; }
|
.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: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; 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:#fbfbf6; color:var(--ink); font:14px/1.55 "DM Mono", monospace; white-space:pre; }.code-block code { font:inherit; }
|
.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:#fbfbf6; 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:#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); }
|
.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); }
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<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><small>Wrap code in triple backticks. Add a language after the opening backticks, for example <code>```python</code>.</small></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>
|
<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>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<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.real_name or 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>
|
||||||
|
|||||||
+1
-1
@@ -1,7 +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.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>
|
<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>
|
||||||
<dialog class="image-dialog"><button class="dialog-close" type="button" aria-label="Close full-size image">Close</button><img alt=""></dialog>
|
<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>
|
<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 %}
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user