Guides image + draft gating, audit retention, ADR-015 gate, backup credential redaction

This commit is contained in:
2026-08-26 15:08:53 -04:00
parent 4e38303326
commit 0738d71525
37 changed files with 1953 additions and 129 deletions

View File

@@ -11,6 +11,7 @@ serving the working copy from the reader's route would quietly undo it.
"""
from datetime import datetime
import json
import re
from flask import Blueprint, request, Response
@@ -19,7 +20,7 @@ from flask_jwt_extended import jwt_required, get_jwt_identity, current_user
from shopdb.api import (
db,
success_response, error_response, ErrorCodes,
require_permission,
require_permission, harden_upload_response,
)
from ..models import Guide, GuideRevision, GuideImage
@@ -109,27 +110,51 @@ def list_guides():
drafts are invisible to readers - but an author must be able to find their
own, or drafting is a trap door.
"""
query = Guide.query
caneditall = _has('guides.edit')
mine = _userid()
# Match against the PUBLISHED text for anything the caller is only allowed
# to read, and against the working copy only for guides they may edit.
# Searching the working copy for everyone leaks draft wording to readers:
# they get a hit on a word that is not in the page when they open it.
published = db.aliased(GuideRevision)
query = Guide.query.outerjoin(
published, Guide.publishedrevisionid == published.guiderevisionid)
search = (request.args.get('q') or '').strip()
if search:
like = '%{}%'.format(search)
query = query.filter(db.or_(
Guide.title.ilike(like),
Guide.summary.ilike(like),
Guide.tags.ilike(like),
def matches(title, summary, tags, blocks):
# The body too: people look for "the one about the drive letter",
# not for a title they never read.
Guide.blocksjson.ilike(like),
))
return db.or_(title.ilike(like), summary.ilike(like),
tags.ilike(like), blocks.ilike(like))
working = matches(Guide.title, Guide.summary, Guide.tags, Guide.blocksjson)
readable = matches(published.title, published.summary,
published.tags, published.blocksjson)
if caneditall:
query = query.filter(db.or_(working, readable))
else:
query = query.filter(db.or_(
readable,
db.and_(Guide.createdby == mine, working),
))
tag = (request.args.get('tag') or '').strip()
if tag:
query = query.filter(Guide.tags.ilike('%{}%'.format(tag)))
like = '%{}%'.format(tag)
if caneditall:
query = query.filter(db.or_(Guide.tags.ilike(like),
published.tags.ilike(like)))
else:
query = query.filter(db.or_(
published.tags.ilike(like),
db.and_(Guide.createdby == mine, Guide.tags.ilike(like)),
))
caneditall = _has('guides.edit')
if not caneditall:
mine = _userid()
query = query.filter(db.or_(
Guide.publishedrevisionid.isnot(None),
Guide.createdby == mine,
@@ -143,6 +168,18 @@ def list_guides():
data['ispublished'] = bool(guide.publishedrevisionid)
data['checklistitems'] = checklistitemcount(guide.blocks)
data['canedit'] = _mayedit(guide)
# A reader's card must describe the guide they will actually open. The
# Guide row carries the working copy, so a retitled draft would list
# under its new name and open under its old one.
if not data['canedit'] and guide.publishedrevisionid:
revision = db.session.get(GuideRevision, guide.publishedrevisionid)
if revision is not None:
data['title'] = revision.title
data['summary'] = revision.summary
data['tags'] = [t.strip() for t in (revision.tags or '').split(',')
if t.strip()]
data['checklistitems'] = checklistitemcount(revision.blocks)
out.append(data)
return success_response(out)
@@ -151,9 +188,24 @@ def list_guides():
@jwt_required()
@require_permission('guides.view')
def list_tags():
"""Every tag in use, with a count, for the filter chips."""
"""Every tag in use, with a count, for the filter chips.
Readers count published tags only. A chip carrying a tag that exists solely
on somebody's draft is both a leak and a dead end - filtering by it returns
nothing a reader is allowed to see.
"""
counts = {}
for (tags,) in db.session.query(Guide.tags).filter(Guide.tags.isnot(None)).all():
if _has('guides.edit'):
pairs = db.session.query(Guide.tags).filter(Guide.tags.isnot(None)).all()
else:
published = db.aliased(GuideRevision)
pairs = db.session.query(published.tags).join(
Guide, Guide.publishedrevisionid == published.guiderevisionid
).filter(published.tags.isnot(None)).all()
mine = _userid()
pairs += db.session.query(Guide.tags).filter(
Guide.createdby == mine, Guide.tags.isnot(None)).all()
for (tags,) in pairs:
for tag in [t.strip() for t in tags.split(',') if t.strip()]:
counts[tag] = counts.get(tag, 0) + 1
return success_response([
@@ -229,6 +281,13 @@ def list_revisions(guideid):
guide = db.session.get(Guide, guideid)
if guide is None:
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found', http_code=404)
# History is editors' business. guides.view alone is permission to read what
# was PUBLISHED; the revision list is every draft this guide has ever been,
# including the ones deliberately not published yet.
if not _mayedit(guide):
return error_response(ErrorCodes.FORBIDDEN,
'You do not have permission to see this history',
http_code=403)
rows = (GuideRevision.query.filter_by(guideid=guideid)
.order_by(GuideRevision.revision.desc()).all())
published = guide.publishedrevisionid
@@ -242,10 +301,27 @@ def list_revisions(guideid):
@jwt_required()
@require_permission('guides.view')
def read_revision(revisionid):
"""One revision by id.
Gated on its PARENT, not on guides.view alone. Revision ids are sequential
integers, so an ungated route here is an invitation to walk 1..n and read
every unpublished draft on the site - which is the opposite of what
the GUIDES wiki page promises about drafts.
A reader may fetch the published revision (it is what the slug route would
serve anyway); everything else takes edit rights on that guide.
"""
revision = db.session.get(GuideRevision, revisionid)
if revision is None:
return error_response(ErrorCodes.NOT_FOUND, 'Revision not found',
http_code=404)
guide = db.session.get(Guide, revision.guideid)
ispublished = bool(guide is not None
and guide.publishedrevisionid == revision.guiderevisionid)
if not ispublished and not (guide is not None and _mayedit(guide)):
# 404, not 403: a draft's existence is itself the thing being withheld.
return error_response(ErrorCodes.NOT_FOUND, 'Revision not found',
http_code=404)
return success_response(revision.to_dict(includeblocks=True))
@@ -311,7 +387,16 @@ def update_guide(guideid):
db.session.rollback()
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
if 'title' in data:
# The slug is set once, at create, and does NOT follow the title.
#
# _slugify's own docstring says the slug is stored so that renaming cannot
# break a link somebody pasted into a work order months ago - and then this
# re-slugged on every save that carried a title, which the editor's 3-second
# autosave does on every keystroke. Fixing a typo in a heading silently
# moved the guide's URL and broke exactly the links the stored slug exists
# to protect. A guide with no slug (older row, or a failed create) still
# gets one here.
if not guide.slug:
guide.slug = _slugify(guide.title, guide.guideid)
db.session.commit()
@@ -426,6 +511,47 @@ def upload_image():
return success_response(image.to_dict(), message='Image uploaded')
def _mayseeimage(image):
"""Whether the caller may fetch this image.
An image is content. One pasted into a published guide must be readable by
anyone who can read that guide; one pasted into a draft must not be, or the
draft leaks a screenshot at a time to anyone willing to count upwards
through image ids.
So: editors of the parent see it, and everyone else sees it only if the
PUBLISHED revision actually references it. An unattached image - pasted
before the guide was first saved - belongs to whoever uploaded it.
"""
if image.guideid is None:
return _has('guides.edit') or image.uploadedby == _userid()
guide = db.session.get(Guide, image.guideid)
if guide is None:
return _has('guides.edit')
if _mayedit(guide):
return True
if not guide.publishedrevisionid:
return False
revision = db.session.get(GuideRevision, guide.publishedrevisionid)
if revision is None:
return False
try:
blocks = json.loads(revision.blocksjson or '[]')
except (TypeError, ValueError):
return False
needle = '/guides/images/{}'.format(image.guideimageid)
for block in blocks:
if not isinstance(block, dict) or block.get('type') != 'image':
continue
if str(block.get('imageid') or '') == str(image.guideimageid):
return True
if needle in str(block.get('url') or ''):
return True
return False
@guides_bp.route('/images/<int:imageid>', methods=['GET'])
@jwt_required()
@require_permission('guides.view')
@@ -433,10 +559,15 @@ def serve_image(imageid):
image = db.session.get(GuideImage, imageid)
if image is None:
return error_response(ErrorCodes.NOT_FOUND, 'Image not found', http_code=404)
return Response(image.data, mimetype=image.mimetype, headers={
if not _mayseeimage(image):
return error_response(ErrorCodes.NOT_FOUND, 'Image not found', http_code=404)
# Same inert-document headers every other upload route serves under: an
# uploaded file is attacker-influenced content and must not be able to run
# as a document in this origin.
return harden_upload_response(Response(image.data, mimetype=image.mimetype, headers={
# Immutable: an image is never edited in place, only replaced by a new
# upload with a new id, so it can be cached hard.
'Cache-Control': 'private, max-age=31536000, immutable',
'Content-Disposition': 'inline; filename="{}"'.format(
image.filename or 'image{}'.format(imageid)),
})
}))