46602f1933
Remove dead code identified in docs/SANITIZATION-REVIEW.md:
- app/blueprints/content_old.py and app/blueprints/playlist.py
- app/models/group.py, app/utils/nginx_config_reader.py
- orphaned templates (content_list, edit_content, upload_content,
player_page) and the related group/Template references
Result: 6 blueprints, 82 routes, no dead modules or orphan templates.
Add files that deploy.sh and docker-entrypoint.sh already require but
which were never tracked:
- https_manager.py (referenced by deploy.sh, migrate_network.sh,
docker-entrypoint.sh)
- Caddyfile.example (seeded by deploy.sh; its absence aborts deploy)
Relocate generated Graphify artifacts from graphify-out/ to
docs/graphify-out/ (110 files, no content change) and archive the
superseded docs under docs/.
Ignore hygiene:
- ignore ad-hoc .env backups (.env.bak*) — they contain live secrets
- keep the pre-sanitization snapshots (docs/legacy code/,
docs/old_code_documentation/) on disk but out of the repo
Fix .env.example: drop a duplicated config block, genericize the
hardcoded host IP, and document HOSTNAME_INTERNAL.
305 lines
12 KiB
Python
305 lines
12 KiB
Python
"""Accurate sanitization report for digiserver-v2.
|
|
|
|
Corrects two things the first pass got wrong:
|
|
* blueprint -> file mapping now follows what app.py actually imports
|
|
* route usage counts url_for() AND hardcoded URL paths in templates/JS
|
|
|
|
Also classifies routes that are intentionally consumed by the external Kivy
|
|
player rather than by any template.
|
|
|
|
Run: python tools/sanitize_report.py [--markdown out.md]
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import ast
|
|
import os
|
|
import re
|
|
from collections import defaultdict
|
|
|
|
# Walk up until we find the project root (robust to the tool living in a
|
|
# nested folder such as docs/tools/).
|
|
_here = os.path.dirname(os.path.abspath(__file__))
|
|
while _here != os.path.dirname(_here) and not os.path.isdir(os.path.join(_here, 'app')):
|
|
_here = os.path.dirname(_here)
|
|
REPO = _here
|
|
APP = os.path.join(REPO, 'app')
|
|
|
|
|
|
def read(p):
|
|
return open(p, encoding='utf-8', errors='replace').read()
|
|
|
|
|
|
def rel(p):
|
|
return os.path.relpath(p, REPO)
|
|
|
|
|
|
def walk_py(roots, skip=('__pycache__', 'legacy code')):
|
|
for root_dir in roots:
|
|
if not os.path.isdir(root_dir):
|
|
continue
|
|
for root, dirs, files in os.walk(root_dir):
|
|
dirs[:] = [d for d in dirs if d not in skip]
|
|
for f in sorted(files):
|
|
if f.endswith('.py'):
|
|
yield os.path.join(root, f)
|
|
|
|
|
|
py_files = list(walk_py([APP, os.path.join(REPO, 'migrations')]))
|
|
src = {rel(p): read(p) for p in py_files}
|
|
app_src = src.get('app/app.py', '')
|
|
|
|
# ── 1. Which blueprint modules does app.py actually import? ─────────────────
|
|
imported_bp_modules = set(re.findall(
|
|
r'from\s+(app\.blueprints\.\w+)\s+import', app_src))
|
|
registered_vars = set(re.findall(r'register_blueprint\((\w+)\)', app_src))
|
|
|
|
# ── 2. Every blueprint definition + the file that defines it ────────────────
|
|
bp_defs = [] # (var, blueprint_name, url_prefix, file, is_imported_by_app)
|
|
for path in py_files:
|
|
r = rel(path)
|
|
if not r.startswith('app/blueprints/') or os.path.basename(path).startswith('__'):
|
|
continue
|
|
s = src[r]
|
|
m = re.search(r'^(\w+_bp)\s*=\s*Blueprint\(\s*[\'"]([\w-]+)[\'"]'
|
|
r'(?:\s*,\s*[^)]*?url_prefix\s*=\s*[\'"]([^\'"]+)[\'"])?',
|
|
s, re.M | re.S)
|
|
if m:
|
|
var, bpname, prefix = m.group(1), m.group(2), m.group(3) or ''
|
|
dotted = 'app.blueprints.' + os.path.basename(path)[:-3]
|
|
bp_defs.append({
|
|
'var': var, 'name': bpname, 'prefix': prefix or '/',
|
|
'file': r, 'imported': dotted in imported_bp_modules,
|
|
'registered_var': var in registered_vars,
|
|
})
|
|
|
|
# ── 3. Collect route usage signals ─────────────────────────────────────────
|
|
url_for_refs = set()
|
|
# Literal URL strings, but only ones that look like real request paths
|
|
# (avoids matching CSS selectors, regexes, mime types, etc.).
|
|
PATH_RE = re.compile(r"['\"](/[a-z][a-zA-Z0-9_\-/<>{}.:]*)['\"]")
|
|
hardcoded_paths = set()
|
|
consider_files = []
|
|
for root, dirs, files in os.walk(os.path.join(APP, 'templates')):
|
|
for f in files:
|
|
if f.endswith(('.html', '.js')):
|
|
consider_files.append(os.path.join(root, f))
|
|
consider_files += [os.path.join(REPO, r) for r in src
|
|
if r.endswith(('.html', '.js'))]
|
|
for fp in consider_files:
|
|
if not os.path.isfile(fp):
|
|
continue
|
|
s = read(fp)
|
|
url_for_refs |= set(re.findall(r"url_for\(\s*['\"]([\w\.]+)['\"]", s))
|
|
hardcoded_paths |= set(PATH_RE.findall(s))
|
|
for r, s in src.items():
|
|
if r.endswith('.py'):
|
|
url_for_refs |= set(re.findall(r"url_for\(\s*['\"]([\w\.]+)['\"]", s))
|
|
hardcoded_paths |= set(PATH_RE.findall(s))
|
|
|
|
# ── 4. Enumerate routes ────────────────────────────────────────────────────
|
|
API_PREFIX = '/api'
|
|
routes = []
|
|
for path in py_files:
|
|
r = rel(path)
|
|
if not r.startswith('app/blueprints/'):
|
|
continue
|
|
s = src[r]
|
|
bp_name = None
|
|
file_prefix = ''
|
|
for d in bp_defs:
|
|
if d['file'] == r:
|
|
bp_name = d['name']
|
|
file_prefix = d['prefix']
|
|
try:
|
|
tree = ast.parse(s)
|
|
except SyntaxError:
|
|
continue
|
|
for node in tree.body:
|
|
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
continue
|
|
for dec in node.decorator_list:
|
|
if not (isinstance(dec, ast.Call) and getattr(dec.func, 'attr', '') == 'route'):
|
|
continue
|
|
rule = dec.args[0].value if dec.args and isinstance(dec.args[0], ast.Constant) else '?'
|
|
methods = 'GET'
|
|
for kw in dec.keywords:
|
|
if kw.arg == 'methods' and isinstance(kw.value, ast.List):
|
|
methods = ','.join(e.value for e in kw.value.elts
|
|
if isinstance(e, ast.Constant))
|
|
full = file_prefix.rstrip('/') + rule if rule != '/' else file_prefix.rstrip('/') + '/'
|
|
endpoint = f'{bp_name}.{node.name}'
|
|
routes.append({
|
|
'endpoint': endpoint, 'func': node.name, 'rule': rule,
|
|
'full': full, 'methods': methods, 'file': r,
|
|
'lineno': node.lineno,
|
|
'url_for_ref': endpoint in url_for_refs,
|
|
'hardcoded_ref': full in hardcoded_paths or rule in hardcoded_paths,
|
|
'is_api': (file_prefix or '').startswith(API_PREFIX),
|
|
})
|
|
|
|
# ── 5. Broken model attribute references ──────────────────────────────────
|
|
model_attrs = defaultdict(set)
|
|
for path in py_files:
|
|
r = rel(path)
|
|
if not r.startswith('app/models/') or os.path.basename(path).startswith('__'):
|
|
continue
|
|
try:
|
|
tree = ast.parse(src[r])
|
|
except SyntaxError:
|
|
continue
|
|
for node in tree.body:
|
|
if isinstance(node, ast.ClassDef):
|
|
for sub in ast.walk(node):
|
|
if isinstance(sub, ast.Assign):
|
|
for t in sub.targets:
|
|
if isinstance(t, ast.Name):
|
|
model_attrs[node.name].add(t.id)
|
|
elif isinstance(sub, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
model_attrs[node.name].add(sub.name)
|
|
|
|
VIA = {'via', 'e', 'exc', 'error', 'err', 'a', 'b', 'flask_', 'db_', 'fh', 'fd'}
|
|
|
|
# ── report ────────────────────────────────────────────────────────────────
|
|
lines = []
|
|
def out(s=''):
|
|
lines.append(s)
|
|
|
|
out('# DigiServer v2 — Code Sanitization Report')
|
|
out()
|
|
out(f'Analysed **{len(py_files)} Python files** under `app/` and `migrations/`.')
|
|
out()
|
|
|
|
out('## 1. Dead modules (never imported)')
|
|
out()
|
|
out('| File | Status |')
|
|
out('|---|---|')
|
|
dead_files = []
|
|
for d in bp_defs:
|
|
if not d['imported']:
|
|
dead_files.append(d['file'])
|
|
out(f"| `{d['file']}` | **DEAD** — defines `{d['var']}` but `app.py` imports another module |")
|
|
for path in py_files:
|
|
r = rel(path)
|
|
base = os.path.basename(path)[:-3]
|
|
if not r.startswith('app/') or r.startswith('app/blueprints/'):
|
|
continue
|
|
if base == '__init__':
|
|
continue
|
|
dotted = r[:-3].replace('/', '.')
|
|
referenced = any(dotted in s or f'.{base} import' in s
|
|
for k, s in src.items() if k != r)
|
|
if not referenced:
|
|
dead_files.append(r)
|
|
out(f'| `{r}` | **DEAD** — never imported by any module |')
|
|
if not dead_files:
|
|
out('| (none) | |')
|
|
out()
|
|
|
|
out('## 2. Blueprint registration')
|
|
out()
|
|
out('| Blueprint var | Module | Prefix | Registered |')
|
|
out('|---|---|---|---|')
|
|
for d in sorted(bp_defs, key=lambda x: x['name']):
|
|
mark = 'yes' if (d['imported'] and d['registered_var']) else '**NO — DEAD**'
|
|
out(f"| `{d['var']}` | `{d['file']}` | `{d['prefix']}` | {mark} |")
|
|
out()
|
|
|
|
used = [r for r in routes if r['url_for_ref'] or r['hardcoded_ref']]
|
|
api_routes = [r for r in routes if r['is_api']]
|
|
unused_non_api = [r for r in routes if not r['url_for_ref'] and not r['hardcoded_ref']
|
|
and not r['is_api']]
|
|
|
|
out('## 3. Routes')
|
|
out()
|
|
out(f'- Total routes: **{len(routes)}**')
|
|
out(f'- Referenced by a template/JS (`url_for` or hardcoded path): **{len(used)}**')
|
|
out(f'- API routes (`/api/*`, consumed by the external Kivy player, not templates): **{len(api_routes)}**')
|
|
out(f'- Non-API routes with **no template reference**: **{len(unused_non_api)}**')
|
|
out()
|
|
out('### 3a. Non-API routes with no template reference (candidates)')
|
|
out()
|
|
out('| Endpoint | Methods | Path | File:line |')
|
|
out('|---|---|---|---|')
|
|
for r in sorted(unused_non_api, key=lambda x: (x['file'], x['lineno'])):
|
|
out(f"| `{r['endpoint']}` | {r['methods']} | `{r['full']}` | `{r['file']}:{r['lineno']}` |")
|
|
out()
|
|
|
|
out('## 4. Broken attribute references (would raise at runtime)')
|
|
out()
|
|
out('| Location | Reference | Problem |')
|
|
out('|---|---|---|')
|
|
# Locals that are known to hold a specific model instance.
|
|
VAR_TO_MODEL = {
|
|
'player': 'Player', 'content': 'Content', 'playlist': 'Playlist',
|
|
'assigned_playlist': 'Playlist', 'feedback': 'PlayerFeedback',
|
|
'latest_feedback': 'PlayerFeedback', 'edit_record': 'PlayerEdit',
|
|
'admin': 'User', 'user': 'User', 'log': 'ServerLog',
|
|
'group': 'Group', 'new_user': 'PlayerUser', 'existing_user': 'PlayerUser',
|
|
}
|
|
# Python/library attributes that would otherwise be false positives.
|
|
SAFE = {
|
|
'query', 'get', 'filter_by', 'first', 'all', 'count', 'id', 'session', 'add',
|
|
'commit', 'rollback', 'delete', 'get_or_404', 'order_by', 'isoformat', 'route',
|
|
'methods', 'get_json', 'args', 'files', 'form', 'json', 'headers', 'values',
|
|
'remote_addr', 'host_url', 'script_root', 'scheme', 'host', 'root_path',
|
|
'config', 'utcnow', 'now', 'total_seconds', 'name', 'value', 'keys', 'items',
|
|
'groups', 'contents', 'players', 'append', 'lower', 'upper', 'strip', 'split',
|
|
'join', 'replace', 'encode', 'decode', 'check_password', 'check_quickconnect_code',
|
|
'set_password', 'set_quickconnect_code', 'authenticate', 'update_status',
|
|
'to_dict', 'is_online', 'is_admin', 'is_active', 'file_size_mb', 'group_count',
|
|
'player_count', 'content_count', 'original_display_name', 'original_media_path',
|
|
'current_media_path', 'get_content_ordered', 'version', 'filename', 'content_type',
|
|
'duration', 'url', 'description', 'file_size', 'uploaded_at', 'hostname',
|
|
'location', 'auth_code', 'orientation', 'status', 'playlist_id', 'last_seen',
|
|
'created_at', 'updated_at', 'deployment_status', 'last_deployment_at',
|
|
'last_deployment_status', 'last_deployment_message', 'original_filename',
|
|
'time_of_modification', 'metadata_path', 'edited_file_path', 'new_name',
|
|
'original_name', 'username', 'role', 'password', 'level', 'message', 'user_code',
|
|
'user_name', 'email', 'func', 'rules', 'player_id', 'content_id', 'error',
|
|
'show', 'seek', 'load', 'play', 'pause', 'stop', 'text', 'bind', 'add_widget',
|
|
'clear_widgets', 'current', 'parent', 'children', 'ids', 'size', 'pos', 'opacity',
|
|
'source', 'state', 'duration', 'position', 'muted', 'audio', 'describe',
|
|
}
|
|
broken = []
|
|
for r, s in src.items():
|
|
if not (r.startswith('app/blueprints/') or r.startswith('app/utils/')):
|
|
continue
|
|
try:
|
|
tree = ast.parse(s)
|
|
except SyntaxError:
|
|
continue
|
|
for node in ast.walk(tree):
|
|
if not (isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name)):
|
|
continue
|
|
var, attr = node.value.id, node.attr
|
|
model = VAR_TO_MODEL.get(var)
|
|
if not model or model not in model_attrs:
|
|
continue
|
|
if attr in model_attrs[model] or attr in SAFE:
|
|
continue
|
|
broken.append((r, node.lineno, f'{var}.{attr}', f'`{model}` has no `{attr}`'))
|
|
for r, ln, ref, prob in broken:
|
|
out(f'| `{r}:{ln}` | `{ref}` | {prob} |')
|
|
if not broken:
|
|
out('| (none) | | |')
|
|
out()
|
|
|
|
out('## 5. How to use this report')
|
|
out()
|
|
out('Each section lists deletion candidates. Reply with the section/symbol names')
|
|
out('you want removed and they will be deleted (the snapshot in `legacy code/`')
|
|
out('preserves the originals).')
|
|
|
|
text = '\n'.join(lines)
|
|
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument('--markdown', default=None)
|
|
args = ap.parse_args()
|
|
|
|
if args.markdown:
|
|
open(args.markdown, 'w').write(text)
|
|
print(f'wrote {args.markdown}')
|
|
else:
|
|
print(text)
|