#!/usr/bin/env python3 """DW store kit: publish.py (DRAFT, not run by the kit authors). Derived from the Ming publish/rollback used for issue #25. Publishes a built OUT_DIR to the tenant's /files with a backup and a one-step rollback. DW_SESSION env var: staff session id (never printed; masked in all output) DW_BRAND_FILE env var: path to a local file holding the brand code (outside any web root; never printed) Usage: python3 publish.py OUT_DIR STATE_DIR # dry run: shows what would be created/updated python3 publish.py OUT_DIR STATE_DIR --apply # backs up live bytes, then creates/updates python3 publish.py OUT_DIR STATE_DIR --rollback # restores backups, deletes files this run created STATE_DIR keeps manifest.json + backups/ (never inside a web root, never committed). """ import base64, hashlib, json, os, sys, urllib.request, urllib.error from pathlib import Path APP = 'https://app.dwalliance.com' TYPES = {'html': 'HTML', 'js': 'JS', 'css': 'CSS', 'json': 'JSON', 'jpg': 'JPG', 'jpeg': 'JPG', 'png': 'PNG', 'webp': 'WEBP', 'svg': 'SVG', 'txt': 'TXT'} SESSION = os.environ['DW_SESSION']; BRAND = Path(os.environ['DW_BRAND_FILE']).read_text().strip() mask = lambda s: str(s).replace(SESSION, '(session)').replace(BRAND, '(brand)') md5 = lambda b: hashlib.md5(b).hexdigest() def post(path, body): b = dict(body, session=SESSION, brand=BRAND) req = urllib.request.Request(APP + path, data=json.dumps(b).encode(), method='POST', headers={'Content-Type': 'application/json'}) try: with urllib.request.urlopen(req, timeout=90) as r: d = json.loads(r.read()) except urllib.error.HTTPError as x: d = json.loads(x.read() or b'{}') for k in ('session', 'brand', 'you_sent'): d.pop(k, None) return d def live_index(): """name/path -> id for the brand's files this session uploaded (/files search returns only the caller's uploads).""" idx = {} for f in post('/files/', {'action': 'search', 'limit': 100}).get('files', []): p = (f.get('file_path') or '').strip('/') idx[(p + '/' if p else '') + f['file_name']] = f['id'] return idx def main(): out, state = Path(sys.argv[1]), Path(sys.argv[2]); state.mkdir(parents=True, exist_ok=True) man_p = state / 'manifest.json' if '--rollback' in sys.argv: m = json.load(open(man_p)) for rel, v in m['updated'].items(): print('restore', rel, post('/files/', {'action': 'update', 'id': v['id'], 'file_data': base64.b64encode((state / 'backups' / rel).read_bytes()).decode()}).get('ok')) for rel, v in m['created'].items(): print('delete', rel, post('/files/', {'action': 'delete', 'id': v['id']}).get('ok')) return apply = '--apply' in sys.argv idx = live_index() print('NOTE: /files search lists only files this person uploaded (max 100). Files uploaded by someone else are not seen here;' ' add their ids to STATE_DIR/known_ids.json as {"path/name": "id"} so they are updated instead of duplicated.') known = state / 'known_ids.json' if known.exists(): idx.update(json.load(open(known))) m = {'created': {}, 'updated': {}} for f in sorted(out.rglob('*')): if not f.is_file(): continue rel = str(f.relative_to(out)); data = f.read_bytes(); path, _, name = rel.rpartition('/') if rel in idx: r = post('/files/', {'action': 'read', 'id': idx[rel]}) live = base64.b64decode(r['file']['file_data']) if r.get('ok') else None if live is None: print('SKIP cannot read', rel, mask(r.get('error'))); continue if live == data: print('same', rel); continue print(('UPDATE ' if apply else 'would update ') + rel) if apply: (state / 'backups' / rel).parent.mkdir(parents=True, exist_ok=True); (state / 'backups' / rel).write_bytes(live) r = post('/files/', {'action': 'update', 'id': idx[rel], 'file_data': base64.b64encode(data).decode()}) m['updated'][rel] = {'id': idx[rel], 'ok': r.get('ok'), 'md5_before': md5(live), 'md5_after': md5(data)} else: print(('CREATE ' if apply else 'would create ') + rel) if apply: r = post('/files/', {'action': 'create', 'file_name': name, 'file_path': path, 'file_type': TYPES[name.rsplit('.', 1)[-1].lower()], 'public_private': 'PUBLIC', 'file_data': base64.b64encode(data).decode()}) m['created'][rel] = {'id': r.get('id'), 'ok': r.get('ok'), 'err': mask(r.get('error')), 'md5': md5(data)} if apply: json.dump(m, open(man_p, 'w'), indent=1); print('manifest', man_p, '— rollback: python3 publish.py', out, state, '--rollback') if __name__ == '__main__': main()