Files
shopdb-flask/plugins/guides/api/routes.py
cproudlock cebb3e9e44 Guides: make images render, and stop drafts leaking to readers
Images were a broken icon for every user. The stored URL was bound straight
to <img src>, and a plain image request carries no Authorization header while
this app keeps its JWT in one, so every fetch was a 401. They are now fetched
through the API client, which also fixes the stored '/api/...' path under a
subpath mount where the API is not at /api at all. A failed image says so in
words rather than rendering as a broken icon, and the editor's own thumbnails
go through the same path - an author needs to see the screenshot they pasted.

Revision and image routes gated on their parent guide. Both were gated on
guides.view alone, and revision ids are sequential, so being able to read one
guide was enough to walk the ids and read every unpublished draft on the site -
the opposite of what docs/GUIDES.md promises. An image in a PUBLISHED guide
still serves to any reader; one that only appears in a draft does not.

The guide list, the tag chips and search now match the PUBLISHED text rather
than the working copy. Matching the working copy handed readers a hit on
wording that is not in the page when they open it.

The slug no longer follows the title. It was regenerated on every save that
carried a title and the editor autosaves every three seconds, so fixing a typo
in a heading silently moved the URL and broke exactly the pasted links the
stored slug exists to protect.

publish() now aborts when the pre-publish save failed. It ran regardless, so
the author saw "Published version N" while readers got the previous revision.

Link rendering: text is escaped before formatting, so a captured URL arrives
carrying entities and was escaped a second time - every multi-parameter
ServiceNow and SharePoint link opened with &amp;amp; in it. Emphasis could also
reach inside an href, and '//host' passed as an in-app link when the browser
reads it as off-site.

Tests are now HTTP-level. The draft-invisibility rule lived entirely in routes
that no test executed, which is how three of them shipped serving drafts.
2026-08-26 15:04:09 -04:00

574 lines
22 KiB
Python

"""Guides API.
TWO WAYS TO READ A GUIDE, on purpose:
GET /api/guides/<slug> what was PUBLISHED
GET /api/guides/<id>/working the working copy, for editors
A reader following a procedure must not have it change under them because
somebody upstairs is editing. The published pointer is what makes that true, and
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
from flask_jwt_extended import jwt_required, get_jwt_identity, current_user
from shopdb.api import (
db,
success_response, error_response, ErrorCodes,
require_permission, harden_upload_response,
)
from ..models import Guide, GuideRevision, GuideImage
from ..services import authoring
from ..services.blocks import BlockError, checklistitemcount
guides_bp = Blueprint('guides', __name__)
# Screenshots of a settings dialog, not photographs. Generous for the former and
# small enough that a pasted phone picture is refused rather than silently
# making a guide take ten seconds to open on a shopfloor PC.
MAXIMAGEBYTES = 8 * 1024 * 1024
ALLOWEDIMAGETYPES = {
'image/png': '.png',
'image/jpeg': '.jpg',
'image/gif': '.gif',
'image/webp': '.webp',
}
def _userid():
if current_user is not None:
return current_user.userid
try:
return int(get_jwt_identity())
except (TypeError, ValueError):
return None
def _has(permission):
"""Whether the caller holds a permission, without raising.
require_permission decorates a route; this is for the in-route decisions -
may this person see drafts, may they edit this particular guide. Same check
the decorator makes, including the admin bypass that lives inside
haspermission.
"""
return bool(current_user is not None
and current_user.haspermission(permission))
def _mayedit(guide):
"""Edit rights for one guide.
guides.edit covers everything. Otherwise an author may edit their OWN guide
while it is unpublished - which is what makes drafting useful without
handing everyone rights over published procedures. Once it is published it
takes guides.edit, because by then other people are following it.
"""
if _has('guides.edit'):
return True
if not _has('guides.create'):
return False
return guide.createdby == _userid() and not guide.publishedrevisionid
def _slugify(title, guideid=None):
"""A readable, stable, unique slug.
Stored rather than derived on read, so renaming a guide cannot break a link
somebody pasted into a work order months ago.
"""
base = re.sub(r'[^a-z0-9]+', '-', (title or '').lower()).strip('-') or 'guide'
base = base[:180] # room for a '-2' style suffix inside the 191 cap
candidate = base
suffix = 2
while True:
clash = Guide.query.filter(Guide.slug == candidate)
if guideid:
clash = clash.filter(Guide.guideid != guideid)
if not clash.first():
return candidate
candidate = '{}-{}'.format(base, suffix)
suffix += 1
# ---------------------------------------------------------------- reading ---
@guides_bp.route('', methods=['GET'])
@jwt_required()
@require_permission('guides.view')
def list_guides():
"""Published guides, plus the caller's own drafts if they can author.
A half-written procedure on a shop floor is worse than no procedure, so
drafts are invisible to readers - but an author must be able to find their
own, or drafting is a trap door.
"""
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)
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.
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:
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)),
))
if not caneditall:
query = query.filter(db.or_(
Guide.publishedrevisionid.isnot(None),
Guide.createdby == mine,
))
rows = query.order_by(Guide.modifieddate.desc()).all()
out = []
for guide in rows:
data = guide.to_dict()
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)
@guides_bp.route('/tags', methods=['GET'])
@jwt_required()
@require_permission('guides.view')
def list_tags():
"""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 = {}
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([
{'tag': tag, 'count': count}
for tag, count in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0].lower()))
])
@guides_bp.route('/<slug>', methods=['GET'])
@jwt_required()
@require_permission('guides.view')
def read_guide(slug):
"""What a reader gets: the published revision.
An unpublished guide is 404 to anyone who cannot edit it - not 403, which
would confirm it exists.
"""
guide = Guide.query.filter_by(slug=slug).first()
if guide is None:
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found', http_code=404)
revision = authoring.readable(guide)
if revision is None:
if not _mayedit(guide):
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found',
http_code=404)
# An editor previewing their own unpublished draft.
data = guide.to_dict(includeblocks=True)
data['ispublished'] = False
data['isdraftpreview'] = True
data['canedit'] = True
return success_response(data)
data = guide.to_dict()
data.update({
'title': revision.title,
'summary': revision.summary,
'tags': [t.strip() for t in (revision.tags or '').split(',') if t.strip()],
'blocks': revision.blocks,
'revision': revision.revision,
'ispublished': True,
'isdraftpreview': False,
'canedit': _mayedit(guide),
# So an editor can see there is newer unpublished work without leaving
# the page.
'hasunpublishedchanges': guide.revision != revision.revision,
})
return success_response(data)
@guides_bp.route('/<int:guideid>/working', methods=['GET'])
@jwt_required()
@require_permission('guides.view')
def read_working(guideid):
"""The working copy, for the editor."""
guide = db.session.get(Guide, guideid)
if guide is None:
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found', http_code=404)
if not _mayedit(guide):
return error_response(ErrorCodes.FORBIDDEN,
'You do not have permission to edit this guide',
http_code=403)
data = guide.to_dict(includeblocks=True)
data['ispublished'] = bool(guide.publishedrevisionid)
data['canedit'] = True
return success_response(data)
@guides_bp.route('/<int:guideid>/revisions', methods=['GET'])
@jwt_required()
@require_permission('guides.view')
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
return success_response([
dict(row.to_dict(), ispublished=(row.guiderevisionid == published))
for row in rows
])
@guides_bp.route('/revisions/<int:revisionid>', methods=['GET'])
@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
docs/GUIDES.md 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))
# ---------------------------------------------------------------- writing ---
@guides_bp.route('', methods=['POST'])
@jwt_required()
@require_permission('guides.create')
def create_guide():
data = request.get_json(silent=True) or {}
title = (data.get('title') or '').strip()
if not title:
return error_response(ErrorCodes.VALIDATION_ERROR, 'A guide needs a title')
guide = Guide(title=title, slug=_slugify(title), status='draft',
createdby=_userid(), updatedby=_userid())
db.session.add(guide)
db.session.flush()
try:
authoring.save(guide,
title=title,
summary=data.get('summary', ''),
tags=data.get('tags', ''),
blocks=data.get('blocks', []),
note=data.get('note'),
userid=_userid())
except BlockError as exc:
db.session.rollback()
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
db.session.commit()
return success_response(guide.to_dict(includeblocks=True),
message='Guide created')
@guides_bp.route('/<int:guideid>', methods=['PUT'])
@jwt_required()
@require_permission('guides.view')
def update_guide(guideid):
guide = db.session.get(Guide, guideid)
if guide is None:
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found', http_code=404)
if not _mayedit(guide):
return error_response(ErrorCodes.FORBIDDEN,
'You do not have permission to edit this guide',
http_code=403)
data = request.get_json(silent=True) or {}
if 'title' in data and not (data.get('title') or '').strip():
return error_response(ErrorCodes.VALIDATION_ERROR, 'A guide needs a title')
try:
guide, revision = authoring.save(
guide,
title=data.get('title'),
summary=data.get('summary'),
tags=data.get('tags'),
blocks=data.get('blocks'),
note=data.get('note'),
userid=_userid())
except BlockError as exc:
db.session.rollback()
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
# 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()
result = guide.to_dict(includeblocks=True)
# 'unchanged' is a real outcome, not a failure: an autosave where the author
# changed nothing must not manufacture a version.
result['saved'] = revision is not None
return success_response(result,
message='Saved' if revision else 'No changes to save')
@guides_bp.route('/<int:guideid>/publish', methods=['POST'])
@jwt_required()
@require_permission('guides.publish')
def publish_guide(guideid):
guide = db.session.get(Guide, guideid)
if guide is None:
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found', http_code=404)
try:
revision = authoring.publish(guide, userid=_userid())
except ValueError as exc:
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
db.session.commit()
return success_response(guide.to_dict(),
message='Published version {}'.format(revision.revision))
@guides_bp.route('/<int:guideid>/unpublish', methods=['POST'])
@jwt_required()
@require_permission('guides.publish')
def unpublish_guide(guideid):
guide = db.session.get(Guide, guideid)
if guide is None:
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found', http_code=404)
authoring.unpublish(guide, userid=_userid())
db.session.commit()
return success_response(guide.to_dict(), message='Withdrawn from readers')
@guides_bp.route('/<int:guideid>/restore/<int:revisionid>', methods=['POST'])
@jwt_required()
@require_permission('guides.view')
def restore_revision(guideid, revisionid):
guide = db.session.get(Guide, guideid)
revision = db.session.get(GuideRevision, revisionid)
if guide is None or revision is None or revision.guideid != guideid:
return error_response(ErrorCodes.NOT_FOUND, 'Revision not found',
http_code=404)
if not _mayedit(guide):
return error_response(ErrorCodes.FORBIDDEN,
'You do not have permission to edit this guide',
http_code=403)
authoring.restore(guide, revision, userid=_userid())
db.session.commit()
return success_response(guide.to_dict(includeblocks=True),
message='Restored version {} as a new draft version'
.format(revision.revision))
@guides_bp.route('/<int:guideid>', methods=['DELETE'])
@jwt_required()
@require_permission('guides.delete')
def delete_guide(guideid):
guide = db.session.get(Guide, guideid)
if guide is None:
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found', http_code=404)
db.session.delete(guide)
db.session.commit()
return success_response({'guideid': guideid}, message='Guide deleted')
# ----------------------------------------------------------------- images ---
@guides_bp.route('/images', methods=['POST'])
@jwt_required()
@require_permission('guides.create')
def upload_image():
"""Take a pasted or dropped image.
Unattached at first: an image is pasted into a NEW guide before that guide
has an id. The editor attaches it on save; anything never attached is
orphaned and prunable, which is a tidier problem than refusing to accept a
paste until the author has saved.
"""
upload = request.files.get('file')
if upload is None:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided')
mimetype = (upload.mimetype or '').lower()
if mimetype not in ALLOWEDIMAGETYPES:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Unsupported image type {!r}. Allowed: {}'.format(
mimetype, ', '.join(sorted(ALLOWEDIMAGETYPES))))
data = upload.read()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'That file is empty')
if len(data) > MAXIMAGEBYTES:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'That image is {:.1f} MB; the limit is {} MB'.format(
len(data) / 1048576, MAXIMAGEBYTES // 1048576))
guideid = request.form.get('guideid', type=int)
image = GuideImage(guideid=guideid, filename=upload.filename,
mimetype=mimetype, bytesize=len(data), data=data,
uploadedby=_userid())
db.session.add(image)
db.session.commit()
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')
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)
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)),
}))