Ports the corporate-GitHub wilm branch onto main, keeping the two branches
separate. Thirteen upstream commits arrive with their authorship intact; the
review fixes and the portability work below are on top.
What arrives:
endpoints plugin - a ShopDB agent reporting endpoint state, telemetry,
services and interfaces, with a command channel the agent
long-polls. Ingest stays on the generic collector route
(ADR-006); the agent key is a UI-managed single-scope
token whose revocation is an instant kill switch.
computers policies - Configuration and Business Policies (RMM-style), scope
trees validated at write time, actions checked against
the endpoints command allowlist. Feature-gated: every
route 404s while the setting is off, so enabling it can
never half-expose the surface.
asset CSV import - templates, a collector script carrying the site path, and
a validate-then-commit import. A plugin can own a virtual
column plus a post-write row hook, which is how a PC's
machine number becomes a controls link without a
hand-built relationships sheet.
agent/ - the Windows agent and its installer.
Contract 0.21.0 -> 0.24.0, additive throughout: register_import_hook (0.22),
mint/list/revoke_service_token (0.23), collector_authorized (0.24). The stability
table had no rows for any of them and now does.
MySQL 5.6 portability. The branch stored three policy columns as sa.JSON, the
only JSON columns in the product, and 5.6 has no JSON type: the migration failed
with a bare syntax error near the word JSON. They are TEXT now, through a
JSONText type decorator, so all seventeen call sites still read and write dicts.
The convention elsewhere is already TEXT (guides.blocksjson,
geenforce.manifestjson).
Review fixes carried on top:
- six policy GETs were jwt_required(optional=True), which authenticates a
caller without checking anything; they require computers.view now, and six
redundant jwt_required decorators are gone
- the agent installer took a default BaseUrl and CollectorKey, so a mistyped
run silently pointed an endpoint at the wrong instance; both are mandatory
and a non-https BaseUrl throws unless -AllowInsecureHttp is passed
- a committing CSV import wrote no audit row
Documentation: the 34 new routes are described in docs/api-inventory.json, which
is hand-maintained on purpose, and the generated spec now covers 331 paths and
477 operations across 22 surfaces. DEVELOPMENT-SETUP's 1071 troubleshooting row
said "use MySQL 8 for dev" and now gives the actual fix, including the trap that
5.6 downgrades ROW_FORMAT=DYNAMIC to Compact with a warning instead of an error.
Full suite 2123 passed, vitest 260 passed, all seventeen plugin chains migrate
clean on MySQL 5.6 and the naming, docs, parity and read-surface gates are green.
632 lines
24 KiB
Python
632 lines
24 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, current_app
|
|
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 _currentuser():
|
|
"""The signed-in user, or None.
|
|
|
|
`current_user` is a LocalProxy, so `current_user is not None` is True even
|
|
when nobody is signed in - the proxy exists, the user behind it does not.
|
|
Under a required JWT that never showed; the read routes are jwt-optional
|
|
now, and the first guest request found it by way of
|
|
"'NoneType' object has no attribute 'haspermission'".
|
|
"""
|
|
try:
|
|
return current_user._get_current_object()
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _userid():
|
|
user = _currentuser()
|
|
if user is not None:
|
|
return 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.
|
|
"""
|
|
user = _currentuser()
|
|
return bool(user is not None and user.haspermission(permission))
|
|
|
|
|
|
def _signedin():
|
|
return _currentuser() is not None
|
|
|
|
|
|
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'])
|
|
@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.
|
|
|
|
A guest reaches this only if the guest role has been granted guides.view.
|
|
A guest is never an author, so the draft branches below cannot open for one.
|
|
"""
|
|
caneditall = _has('guides.edit')
|
|
# A guest has no user id, and `createdby == None` is a real match in SQL
|
|
# against every guide imported without an author. Their drafts are not
|
|
# theirs; nobody's are.
|
|
mine = _userid() if _signedin() else -1
|
|
|
|
# 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'])
|
|
@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'])
|
|
@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'])
|
|
@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'])
|
|
@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'])
|
|
@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))
|
|
|
|
|
|
# ---------------------------------------------------------------- writing ---
|
|
|
|
def _attachimages(guide, blocks):
|
|
"""Attach the images this guide's blocks reference but nothing owns yet.
|
|
|
|
An image pasted into a guide that has not been saved is uploaded with no
|
|
guideid - there is no id to give it. The upload route says the editor
|
|
attaches it on save, and the editor never did: it only sends a guideid when
|
|
the guide already has one. So a screenshot pasted while writing a NEW guide
|
|
stayed unattached forever.
|
|
|
|
An unattached image is visible to editors and to whoever uploaded it, and
|
|
to nobody else - so the guide looked right to the person who wrote it and
|
|
showed "Image unavailable" to every reader. Attaching on save is what makes
|
|
the image belong to the guide, and therefore readable by anyone allowed to
|
|
read the guide once it is published.
|
|
|
|
Only images nothing else owns are claimed: an image already attached to
|
|
another guide is left alone rather than stolen by whoever references it.
|
|
"""
|
|
referenced = set()
|
|
for block in blocks or []:
|
|
if isinstance(block, dict) and block.get('imageid') is not None:
|
|
try:
|
|
referenced.add(int(block['imageid']))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if not referenced:
|
|
return
|
|
(GuideImage.query
|
|
.filter(GuideImage.guideimageid.in_(referenced))
|
|
.filter(GuideImage.guideid.is_(None))
|
|
.update({'guideid': guide.guideid}, synchronize_session=False))
|
|
|
|
|
|
@guides_bp.route('', methods=['POST'])
|
|
@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))
|
|
|
|
# The first save is exactly when a pasted image has no owner yet.
|
|
_attachimages(guide, guide.blocks)
|
|
|
|
db.session.commit()
|
|
return success_response(guide.to_dict(includeblocks=True),
|
|
message='Guide created')
|
|
|
|
|
|
@guides_bp.route('/<int:guideid>', methods=['PUT'])
|
|
@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))
|
|
|
|
_attachimages(guide, guide.blocks)
|
|
|
|
# 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'])
|
|
@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'])
|
|
@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'])
|
|
@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'])
|
|
@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'])
|
|
@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'])
|
|
@require_permission('guides.view')
|
|
def serve_image(imageid):
|
|
"""The image itself, under the same rule as the guide that holds it.
|
|
|
|
A guest who may read a published guide must see its screenshots, or every
|
|
image renders as "Image unavailable" and the guide looks broken.
|
|
_mayseeimage already answers correctly for somebody holding nothing - it
|
|
requires the PUBLISHED revision to reference the image - so a guest needs no
|
|
separate branch here.
|
|
"""
|
|
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)),
|
|
}))
|