"""Block validation. The editor is the only thing that writes blocks today, so this is not defending against a hostile author - it is defending against a BAD SAVE. A block list that is wrong in a way nobody notices at save time becomes a guide that will not render weeks later, when the person who wrote it has moved on and the only evidence is a blank page. So: validate at the door, name the block that is wrong, and refuse the save. The alternative - storing whatever arrives and coping at render time - was tried in the backups plugin with a manifest and produced exactly the failure this avoids: a client bug turning into a broken page that outlived it. WHAT IS NOT VALIDATED HERE: the inline text. It is a restricted Markdown subset rendered through a sanitiser in the browser, which is where a renderer belongs. Storing text verbatim keeps what the author typed recoverable even if the rendering rules change later. """ import hashlib import json from ..models import BLOCKTYPES # A guide is a procedure, not a document repository. This cap is generous for # anything a person will read on a shop floor and small enough that a runaway # paste cannot fill a MEDIUMTEXT column. MAXBLOCKSBYTES = 2 * 1024 * 1024 MAXBLOCKS = 500 MAXTEXT = 20000 class BlockError(ValueError): """Raised with a message naming the offending block, for the API to relay.""" def _fail(index, message): raise BlockError('block {}: {}'.format(index + 1, message)) def validate(blocks): """Check a block list, returning it unchanged, or raise BlockError. Kept pure - no database, no request - so the rules are testable directly and the same function can be reused by an importer later. """ if not isinstance(blocks, list): raise BlockError('blocks must be a list') if len(blocks) > MAXBLOCKS: raise BlockError('a guide is limited to {} blocks'.format(MAXBLOCKS)) seen = set() for index, block in enumerate(blocks): if not isinstance(block, dict): _fail(index, 'must be an object') blockid = block.get('id') if not isinstance(blockid, str) or not blockid.strip(): # Ids are what make a checklist item rememberable between visits. # A block without one silently loses its tick the next time the # guide is opened, which reads as "the checkboxes do not work". _fail(index, 'needs a stable id') if blockid in seen: _fail(index, 'duplicate id {!r} - ticks would follow the wrong item' .format(blockid)) seen.add(blockid) blocktype = block.get('type') if blocktype not in BLOCKTYPES: _fail(index, 'unknown type {!r}. Known types: {}'.format( blocktype, ', '.join(BLOCKTYPES))) text = block.get('text') if text is not None: if not isinstance(text, str): _fail(index, 'text must be a string') if len(text) > MAXTEXT: _fail(index, 'text is longer than {} characters'.format(MAXTEXT)) items = block.get('items') if items is not None: if not isinstance(items, list): _fail(index, 'items must be a list') for item in items: # Checklist items carry their own id for the same reason blocks # do: that id is the thing a tick is stored against. if isinstance(item, dict): if not isinstance(item.get('id'), str) or not item['id'].strip(): _fail(index, 'every item needs an id') if not isinstance(item.get('text', ''), str): _fail(index, 'item text must be a string') elif not isinstance(item, str): _fail(index, 'items must be strings or objects') encoded = json.dumps(blocks, separators=(',', ':')) if len(encoded.encode('utf-8')) > MAXBLOCKSBYTES: raise BlockError('this guide is larger than {} MB'.format( MAXBLOCKSBYTES // (1024 * 1024))) return blocks def plaintext(blocks): """Flatten a guide to searchable text. Search should find a guide by something written halfway down it, not only by its title - that is how people actually look for a procedure ("the one about the drive letter"). Cheap because it runs on save, not on every query. """ parts = [] for block in blocks or []: if not isinstance(block, dict): continue if isinstance(block.get('text'), str): parts.append(block['text']) for item in block.get('items') or []: if isinstance(item, str): parts.append(item) elif isinstance(item, dict) and isinstance(item.get('text'), str): parts.append(item['text']) for row in block.get('rows') or []: if isinstance(row, list): parts.extend(cell for cell in row if isinstance(cell, str)) return '\n'.join(parts) def checklistitemcount(blocks): """How many tickable things a guide has, for the card and the progress bar. Checklist items, plus the sections a reader crosses off to keep their place: a step, or a heading. A guide converted from an article often has fifty headings and no steps at all, and "0 steps to tick" on a card that is entirely tickable reads as a broken feature. """ total = 0 for block in blocks or []: if not isinstance(block, dict): continue if block.get('type') == 'checklist': total += len(block.get('items') or []) elif block.get('type') in ('step', 'heading'): total += 1 return total def contenthash(title, summary, tags, blocks): """sha256 over everything a reader would notice changing. Title, summary and tags are included, not just the blocks: renaming a guide or retagging it is a change worth a revision, and hashing only the body would silently drop it from the history. Canonical by construction - the blocks are re-serialized with sorted keys and no whitespace, so the same content saved twice hashes the same however the editor happened to order its JSON that day. """ canonical = json.dumps({ 'title': (title or '').strip(), 'summary': (summary or '').strip(), 'tags': sorted(t.strip().lower() for t in (tags or '').split(',') if t.strip()), 'blocks': blocks or [], }, sort_keys=True, separators=(',', ':')) return hashlib.sha256(canonical.encode('utf-8')).hexdigest()