Files
ske087 46602f1933 Sanitize codebase, reorganize docs, and add missing deploy files
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.
2026-09-11 12:18:34 +03:00

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)